Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ dmg: verify-arch
test: test-zcash-cli test-unit

test-unit:
cd $(PROJECT_DIR) && bun test __tests__/swap-parsing.test.ts __tests__/engine-state-machine.test.ts __tests__/device-switch.test.ts __tests__/wizard-messaging.test.ts __tests__/solana-tx.test.ts __tests__/solana-message-parser.test.ts __tests__/solana-instruction-decoder.test.ts __tests__/solana-alt.test.ts __tests__/solana-spl-decimals.test.ts __tests__/ton-build.test.ts __tests__/tron-memo-inject.test.ts __tests__/audit-coverage.test.ts __tests__/chain-scan.test.ts __tests__/recovery-ownership.test.ts src/bun/mcp.test.ts src/bun/txbuilder/hive-ops.test.ts
cd $(PROJECT_DIR) && bun test __tests__/evm-signer-verify.test.ts __tests__/swap-parsing.test.ts __tests__/engine-state-machine.test.ts __tests__/device-switch.test.ts __tests__/wizard-messaging.test.ts __tests__/solana-tx.test.ts __tests__/solana-message-parser.test.ts __tests__/solana-instruction-decoder.test.ts __tests__/solana-alt.test.ts __tests__/solana-spl-decimals.test.ts __tests__/ton-build.test.ts __tests__/tron-memo-inject.test.ts __tests__/audit-coverage.test.ts __tests__/chain-scan.test.ts __tests__/recovery-ownership.test.ts src/bun/mcp.test.ts src/bun/txbuilder/hive-ops.test.ts

test-integration: test-rest

Expand Down
52 changes: 52 additions & 0 deletions projects/keepkey-vault/__tests__/evm-signer-verify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, test } from 'bun:test'
import { ethers } from 'ethers'
import { EvmSignerVerificationError, verifyEvmSigner } from '../src/bun/evm-rpc'

// Fixed key so the expectations below are stable; funds never touch this.
const TEST_KEY = '0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318'
const wallet = new ethers.Wallet(TEST_KEY)

/** A type-2 tx with calldata large enough to require multi-chunk transport. */
async function signLargeCalldataTx(): Promise<string> {
return wallet.signTransaction({
to: '0x4c82d1fbfe28c977cbb58d8c7ff8fcf9f70a2cca',
value: 0,
nonce: 495,
gasLimit: ethers.BigNumber.from('0x6c8b8'),
maxFeePerGas: ethers.BigNumber.from('0x291d5740f'),
maxPriorityFeePerGas: ethers.BigNumber.from('0x218711a00'),
chainId: 1,
type: 2,
data: '0x3593564c' + '00'.repeat(1600),
})
}

describe('verifyEvmSigner', () => {
test('accepts a signature that recovers to the expected account', async () => {
const signed = await signLargeCalldataTx()
await expect(verifyEvmSigner(signed, wallet.address)).resolves.toBeUndefined()
})

test('accepts a checksum-mismatched expectation (case-insensitive compare)', async () => {
const signed = await signLargeCalldataTx()
await expect(verifyEvmSigner(signed, wallet.address.toLowerCase())).resolves.toBeUndefined()
})

test('rejects when the signature recovers to a different account', async () => {
// This is the firmware 7.x.0–7.14.0 EIP-1559 chunked-data failure in
// miniature: the bytes are a perfectly valid signature, they just do not
// belong to the account we asked to sign. Broadcasting is accepted by the
// RPC and then dropped from the mempool forever, so it must throw here.
const signed = await signLargeCalldataTx()
const someoneElse = ethers.Wallet.createRandom().address

await expect(verifyEvmSigner(signed, someoneElse)).rejects.toBeInstanceOf(EvmSignerVerificationError)
await expect(verifyEvmSigner(signed, someoneElse)).rejects.toThrow(/recovered signer/i)
await expect(verifyEvmSigner(signed, someoneElse)).rejects.toThrow(/7\.14\.1/)
})

test('rejects unparseable bytes rather than letting them through', async () => {
await expect(verifyEvmSigner('0xdeadbeef', wallet.address)).rejects.toBeInstanceOf(EvmSignerVerificationError)
await expect(verifyEvmSigner('0xdeadbeef', wallet.address)).rejects.toThrow(/could not be parsed/i)
})
})
59 changes: 58 additions & 1 deletion projects/keepkey-vault/src/bun/evm-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,65 @@ export async function estimateGas(
}
}

export async function broadcastEvmTx(rpcUrl: string, signedTxHex: string): Promise<string> {
/**
* Assert that a device-signed transaction actually recovers to the account we
* asked the device to sign for. Throws (before any broadcast) when it doesn't.
*
* This is not paranoia about a compromised device — it catches signatures that
* are cryptographically valid over the *wrong pre-image*. KeepKey firmware
* 7.x.0 through 7.14.0 hashes the EIP-1559 empty access-list byte (0xC0) too
* early, so for any tx whose calldata exceeds the 1024-byte single-chunk limit
* the byte lands mid-stream instead of closing the RLP body. The resulting
* signature passes every check an RPC node makes, so the broadcast is
* ACCEPTED — and then the tx is dropped from the mempool forever, because the
* address it recovers to has no balance and the wrong nonce. Fixed in
* firmware 7.14.1; the stable release channel still ships an affected build,
* so the guard has to live here.
*
* Failing loudly before broadcast turns a silently-stuck transaction (and, on
* a swap, a spent approval with no swap) into an actionable error.
*/
export class EvmSignerVerificationError extends Error {
constructor(message: string) {
super(message)
this.name = 'EvmSignerVerificationError'
}
}

export async function verifyEvmSigner(signedTxHex: string, expectedFrom: string): Promise<void> {
const hex = signedTxHex.startsWith('0x') ? signedTxHex : `0x${signedTxHex}`
const { ethers } = await import('ethers')

let recovered: string
try {
recovered = ethers.utils.parseTransaction(hex).from ?? ''
} catch (e: any) {
throw new EvmSignerVerificationError(
`Refusing to broadcast: the signed transaction could not be parsed (${e?.message ?? e}). ` +
`This usually means the device returned a malformed signature.`,
)
}

if (!recovered || recovered.toLowerCase() !== expectedFrom.toLowerCase()) {
throw new EvmSignerVerificationError(
`Refusing to broadcast: recovered signer ${recovered || '(none)'} ≠ expected ${expectedFrom}. ` +
`The signed bytes do not represent a transaction from your account. ` +
`If this transaction has large contract data, update your KeepKey to firmware 7.14.1 or later.`,
)
}
}

/**
* Broadcast a signed EVM transaction.
*
* `expectedFrom` is required on purpose: every caller here is broadcasting
* bytes that came back from the device, and a silently mis-signed tx is
* unrecoverable once it leaves. Making the parameter mandatory means a new
* broadcast path cannot forget the check — see verifyEvmSigner for why.
*/
export async function broadcastEvmTx(rpcUrl: string, signedTxHex: string, expectedFrom: string): Promise<string> {
const hex = signedTxHex.startsWith('0x') ? signedTxHex : `0x${signedTxHex}`
await verifyEvmSigner(hex, expectedFrom)
const result = await ethRpc(rpcUrl, 'eth_sendRawTransaction', [hex])
return result
}
Expand Down
7 changes: 6 additions & 1 deletion projects/keepkey-vault/src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4654,7 +4654,12 @@ const rpc = BrowserView.defineRPC<VaultRPCSchema>({
if (rpcUrl) {
const serialized = params.signedTx?.serializedTx || params.signedTx?.serialized || (typeof params.signedTx === 'string' ? params.signedTx : undefined)
if (!serialized || typeof serialized !== 'string') throw new Error(`Cannot extract serialized tx from: ${JSON.stringify(params.signedTx).slice(0, 200)}`)
const txid = await broadcastEvmTx(rpcUrl, serialized)
// Build/sign/broadcast are separate RPC calls. The selected account
// can change between them, so verification must use the address that
// was attached to this signed request.
const expectedFrom = params.fromAddress
if (!expectedFrom) throw new Error('Cannot verify signer: signed EVM request has no fromAddress')
const txid = await broadcastEvmTx(rpcUrl, serialized, expectedFrom)
result = { txid }
} else {
const pioneer = await getPioneer()
Expand Down
13 changes: 9 additions & 4 deletions projects/keepkey-vault/src/bun/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { findEvmSchema } from './evm-schema-registry'
import { findSolanaSchema } from './solana-schema-registry'
import { getPioneer } from './pioneer'
import { encodeDepositWithExpiry, encodeApprove, parseUnits, toHex } from './txbuilder/evm'
import { getEvmGasPrice, getEvmFeeData, getEvmNonce, getEvmBalance, getErc20Allowance, getErc20Balance, getErc20Decimals, broadcastEvmTx, waitForTxReceipt, estimateGas } from './evm-rpc'
import { getEvmGasPrice, getEvmFeeData, getEvmNonce, getEvmBalance, getErc20Allowance, getErc20Balance, getErc20Decimals, broadcastEvmTx, EvmSignerVerificationError, waitForTxReceipt, estimateGas } from './evm-rpc'
import * as txb from './txbuilder'
import { normalizeBchAddress } from './txbuilder'
// Re-export pure parsing functions (used by tests + this module)
Expand Down Expand Up @@ -708,7 +708,7 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext):
const rpcUrl = getRpcUrl(fromChain)
stage('approve-broadcasting')
if (rpcUrl) {
approvalTxid = await broadcastEvmTx(rpcUrl, approveHex)
approvalTxid = await broadcastEvmTx(rpcUrl, approveHex, fromAddress)
swapLog(`${TAG} Relay-path approve broadcast: ${approvalTxid}`)
stage('approve-waiting-receipt')
const receipt = await waitForTxReceipt(rpcUrl, approvalTxid, 180_000)
Expand Down Expand Up @@ -1017,9 +1017,14 @@ export async function executeSwap(params: ExecuteSwapParams, ctx: SwapContext):
throw new Error(`Cannot extract serialized tx from signed result: ${JSON.stringify(signedTx).slice(0, 200)}`)
}
try {
txid = await broadcastEvmTx(swapRpcUrl, serializedHex)
txid = await broadcastEvmTx(swapRpcUrl, serializedHex, fromAddress)
swapLog(`${TAG} Broadcast via direct RPC: ${txid}`)
} catch (directErr: any) {
// A signer-verification failure is a local safety decision, not an RPC
// availability problem. Never route the rejected bytes around the guard
// through Pioneer.
if (directErr instanceof EvmSignerVerificationError) throw directErr

// The pre-sign balance check in buildRelaySwapTx already verified
// value + gas <= native balance against this same RPC URL. So a node
// "insufficient funds" here is NOT a real gas shortage — it's a stale
Expand Down Expand Up @@ -1883,7 +1888,7 @@ async function buildEvmSwapTx(
// Broadcast approve tx
if (rpcUrl) {
stage('approve-broadcasting')
approvalTxid = await broadcastEvmTx(rpcUrl, approveHex)
approvalTxid = await broadcastEvmTx(rpcUrl, approveHex, fromAddress)
swapLog(`${TAG} Approve tx broadcast (direct RPC): ${approvalTxid}`)

// Wait for approval receipt before building deposit — prevents nonce gap if approval reverts.
Expand Down
7 changes: 7 additions & 0 deletions projects/keepkey-vault/src/bun/walletconnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { SessionTypes, SignClientTypes } from '@walletconnect/types'
import bs58 from 'bs58'
import type { SigningRequestInfo, WcSessionInfo } from '../shared/types'
import { evmAddressPath } from './evm-addresses'
import { verifyEvmSigner } from './evm-rpc'
import { parseSolanaTx } from './solana-tx'
import { buildSolanaMessageDecodedInfo } from './solana-message-preview'

Expand Down Expand Up @@ -916,6 +917,12 @@ export class WalletConnectManager {
throw new Error('Device did not return serialized transaction')
}

// Verify before the bytes leave the vault — whether we broadcast them or
// hand them back to the dApp to broadcast itself. A signature over a
// mangled pre-image is accepted by RPC nodes and then silently dropped
// from the mempool; see verifyEvmSigner in evm-rpc.ts.
await verifyEvmSigner(result.serialized, from)

if (broadcast) {
const txHash = await this.rpcCall(effectiveChainId, 'eth_sendRawTransaction', [result.serialized])
return txHash
Expand Down
12 changes: 9 additions & 3 deletions projects/keepkey-vault/src/mainview/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"
import { CHAINS, customChainToChainDef, isChainSupported, type ChainDef } from "../../shared/chains"
import { isBitcoinOnlyVariant } from "../../shared/flags"
import { versionCompare } from "../../shared/firmware-versions"
import { formatBalance } from "../lib/formatting"
import { formatBalance, hasNonZeroBalance } from "../lib/formatting"
import { AnimatedUsd } from "./AnimatedUsd"
import { getAssetIcon, registerCustomAsset } from "../../shared/assetLookup"
import { AssetPage } from "./AssetPage"
Expand Down Expand Up @@ -2534,9 +2534,15 @@ export function Dashboard({ onLoaded, watchOnly, watchOnlyDeviceId, onOpenSettin
const agg = balances.get(drilledChainId)
const isEvm = dchain.chainFamily === 'evm'
const fundedAddrs = isEvm
? evmAddressSet.addresses.filter(a => (a.chainBalances?.[drilledChainId]?.balanceUsd ?? 0) > 0)
? evmAddressSet.addresses.filter(a => {
const cb = a.chainBalances?.[drilledChainId]
return (cb?.balanceUsd ?? 0) > 0 || hasNonZeroBalance(cb?.balance)
})
: []
const hasAggregate = (agg?.balanceUsd ?? 0) > 0
// Chains whose price feed is missing (e.g. Arbitrum native ETH)
// report balanceUsd 0 while still holding funds. Gate on the
// token amount so held funds never render as "no balance".
const hasAggregate = (agg?.balanceUsd ?? 0) > 0 || hasNonZeroBalance(agg?.balance)
return (
<Flex direction="column" align="center" justify="center" gap="4" maxW="360px" textAlign="center">
<Image
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ function Confetti() {
type Avail = { success: boolean; available: boolean; reason?: string }
type CreateResp = { status: number; success?: boolean; txid?: string; username?: string; error?: string; retryAfter?: number }

// Chakra's bare `variant="outline"` resolves to the gray palette's light-mode
// pair (dark text on a bright border), which inverts against the glass card.
// Every other outline button in the app passes colors explicitly; these match
// that idiom using the v3 tokens this panel already speaks.
const outlineBtn = {
color: "var(--text-1)",
borderColor: "var(--line-2)",
bg: "rgba(255, 255, 255, 0.03)",
_hover: {
color: "var(--text-0)",
borderColor: "rgba(255, 255, 255, 0.22)",
bg: "rgba(255, 255, 255, 0.07)",
},
} as const

// Standalone copy-icon button with its own transient "copied" state.
function CopyBtn({ value, label }: { value: string; label: string }) {
const [copied, setCopied] = useState(false)
Expand Down Expand Up @@ -93,7 +108,7 @@ export function HiveAccountPanel({ activeKey, color, loading, deriveError, onRet
if (deriveError || !loading) return (
<Box className="v3-glass-card" p="4" mt="4">
<Text fontSize="13px" color="var(--text-2)">{deriveError || "Couldn't derive your Hive key from the device."}</Text>
{onRetryDerive && <Button mt="3" size="sm" variant="outline" onClick={onRetryDerive}>Retry</Button>}
{onRetryDerive && <Button mt="3" size="sm" variant="outline" {...outlineBtn} onClick={onRetryDerive}>Retry</Button>}
</Box>
)
return <Flex justify="center" py="10"><Spinner color={color} /></Flex>
Expand All @@ -104,7 +119,7 @@ export function HiveAccountPanel({ activeKey, color, loading, deriveError, onRet
if (state === "error") return (
<Box className="v3-glass-card" p="4" mt="4">
<Text fontSize="13px" color="var(--text-2)">Couldn't reach the Hive account service. Try again shortly.</Text>
<Button mt="3" size="sm" variant="outline" onClick={refresh}>Retry</Button>
<Button mt="3" size="sm" variant="outline" {...outlineBtn} onClick={refresh}>Retry</Button>
</Box>
)

Expand All @@ -128,9 +143,9 @@ export function HiveAccountPanel({ activeKey, color, loading, deriveError, onRet
{ label: "Hive.blog", url: `https://hive.blog/@${account.name}` },
{ label: "Ecency", url: `https://ecency.com/@${account.name}` },
].map(l => (
<Button key={l.label} size="xs" variant="outline" gap="1.5"
<Button key={l.label} size="xs" variant="outline" gap="1.5" px="2.5" fontWeight="500" {...outlineBtn}
onClick={() => rpcRequest("openUrl", { url: l.url }).catch(() => {})}>
{l.label}<Box as={FaExternalLinkAlt} fontSize="9px" />
{l.label}<Box as={FaExternalLinkAlt} fontSize="9px" opacity="0.55" />
</Button>
))}
</Flex>
Expand Down
Loading
Loading