From f17a86a1aa42eb0c5072d35bfe1e135142ac729b Mon Sep 17 00:00:00 2001 From: Grayson Ho Date: Fri, 29 May 2026 09:49:05 +0100 Subject: [PATCH] fix(bridge): block cross-chain destination backed by random key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a wallet was imported via single-curve private key (`zerion wallet import --evm-key` or `--sol-key`), OWS generates a random key for the other curve so the wallet still exposes both EVM and Solana addresses. `exportWallet` returns only the imported secret — the random key is not recoverable outside the CLI vault. Bridge auto-derived the cross-chain receiver from the source wallet's other-curve address. For EVM-key wallets bridging to Solana (and the reverse), this silently sent funds to an address the user could only sign for from this CLI. Lose the vault, lose the funds. Now `resolveDestination` checks wallet origin before falling back to the source wallet or accepting `--to-wallet`. Curve mismatch throws with guidance to pass `--to-address ` to a wallet the user actually controls. Mnemonic and OWS-direct wallets stay permissive (their other-curve keys are real). 4 new tests cover both directions and the explicit-address bypass. Reported by Horjet in user feedback. The `--to-address` flag was already supported; the gap was the auto-derive path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../unit/cli/utils/wallet/resolve.test.mjs | 59 +++++++++++++++++++ cli/utils/wallet/resolve.js | 28 ++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/cli/tests/unit/cli/utils/wallet/resolve.test.mjs b/cli/tests/unit/cli/utils/wallet/resolve.test.mjs index bdccdb3b..ff21374b 100644 --- a/cli/tests/unit/cli/utils/wallet/resolve.test.mjs +++ b/cli/tests/unit/cli/utils/wallet/resolve.test.mjs @@ -6,16 +6,34 @@ import { describe, it, before, after } from "node:test"; import assert from "node:assert/strict"; import { resolveDestination } from "#zerion/utils/wallet/resolve.js"; import * as ows from "#zerion/utils/wallet/keystore.js"; +import { setWalletOrigin, removeWalletOrigin } from "#zerion/utils/config.js"; +import { WALLET_ORIGIN } from "#zerion/utils/common/constants.js"; const MULTI = "resolve-test-multi"; +const EVM_KEY_WALLET = "resolve-test-evm-key"; +const SOL_KEY_WALLET = "resolve-test-sol-key"; + +// Deterministic test keys — never used on mainnet. +const TEST_EVM_KEY = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; +const TEST_SOL_KEY = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; before(() => { try { ows.deleteWallet(MULTI); } catch {} + try { ows.deleteWallet(EVM_KEY_WALLET); } catch {} + try { ows.deleteWallet(SOL_KEY_WALLET); } catch {} ows.createWallet(MULTI); // mnemonic-derived → has both EVM + Solana + ows.importFromKey(EVM_KEY_WALLET, TEST_EVM_KEY, undefined, "evm"); + setWalletOrigin(EVM_KEY_WALLET, WALLET_ORIGIN.EVM_KEY); + ows.importFromKey(SOL_KEY_WALLET, TEST_SOL_KEY, undefined, "solana"); + setWalletOrigin(SOL_KEY_WALLET, WALLET_ORIGIN.SOL_KEY); }); after(() => { try { ows.deleteWallet(MULTI); } catch {} + try { ows.deleteWallet(EVM_KEY_WALLET); } catch {} + try { ows.deleteWallet(SOL_KEY_WALLET); } catch {} + removeWalletOrigin(EVM_KEY_WALLET); + removeWalletOrigin(SOL_KEY_WALLET); }); describe("resolveDestination — chain-aware receiver picker", () => { @@ -90,4 +108,45 @@ describe("resolveDestination — chain-aware receiver picker", () => { /Cross-chain destination required/i ); }); + + it("blocks fallback to EVM-key wallet when target is Solana (random ed25519 key)", async () => { + await assert.rejects( + resolveDestination({ + fallbackWallet: EVM_KEY_WALLET, + targetChain: "solana", + }), + /imported from an EVM private key.*not recoverable/is + ); + }); + + it("blocks --to-wallet pointing at EVM-key wallet when target is Solana", async () => { + await assert.rejects( + resolveDestination({ + toWalletName: EVM_KEY_WALLET, + targetChain: "solana", + }), + /imported from an EVM private key.*not recoverable/is + ); + }); + + it("blocks fallback to Solana-key wallet when target is EVM (random secp256k1 key)", async () => { + await assert.rejects( + resolveDestination({ + fallbackWallet: SOL_KEY_WALLET, + targetChain: "ethereum", + }), + /imported from a Solana private key.*not recoverable/is + ); + }); + + it("still allows explicit --to-address for EVM-key source bridging to Solana", async () => { + const sol = "8xLdoxKr3J5dQX2dQuzC7v3sqXq6ZwVz1aVzaB6gqW9F"; + const dest = await resolveDestination({ + toAddressOrEns: sol, + fallbackWallet: EVM_KEY_WALLET, + targetChain: "solana", + }); + assert.equal(dest.address, sol); + assert.equal(dest.source, "address"); + }); }); diff --git a/cli/utils/wallet/resolve.js b/cli/utils/wallet/resolve.js index e4b006f5..f6c37dd3 100644 --- a/cli/utils/wallet/resolve.js +++ b/cli/utils/wallet/resolve.js @@ -5,10 +5,11 @@ import { createPublicClient, http } from "viem"; import { mainnet } from "viem/chains"; import * as ows from "./keystore.js"; -import { getConfigValue } from "../config.js"; +import { getConfigValue, getWalletOrigin } from "../config.js"; import { isSolana } from "../chain/registry.js"; import { printError } from "../common/output.js"; import { resolveWatchAddress } from "./watchlist.js"; +import { WALLET_ORIGIN } from "../common/constants.js"; const ENS_TIMEOUT_MS = 10_000; const ENS_RETRIES = 2; @@ -157,6 +158,31 @@ export async function resolveDestination({ toAddressOrEns, toWalletName, fallbac ); } + // Block auto-derived receivers when the wallet was imported via a single-curve + // private key. OWS still generates a random key for the other curve so the + // wallet exposes an address there, but that key is not recoverable from the + // imported secret — bridging funds to it locks them to this CLI vault. Pass + // --to-address to send to a wallet you actually control. + const origin = getWalletOrigin(lookupWallet); + if (wantsSolana && origin === WALLET_ORIGIN.EVM_KEY) { + throw new Error( + `Wallet "${lookupWallet}" was imported from an EVM private key, so its Solana ` + + `address is backed by a random key that is not recoverable from your imported secret. ` + + `Bridging to it would lock funds to this CLI vault.\n\n` + + `Pass --to-address to send to a Solana wallet you control, ` + + `or --to-wallet for a wallet imported via mnemonic or Solana private key.` + ); + } + if (!wantsSolana && origin === WALLET_ORIGIN.SOL_KEY) { + throw new Error( + `Wallet "${lookupWallet}" was imported from a Solana private key, so its EVM ` + + `address is backed by a random key that is not recoverable from your imported secret. ` + + `Bridging to it would lock funds to this CLI vault.\n\n` + + `Pass --to-address <0x…/ENS> to send to an EVM wallet you control, ` + + `or --to-wallet for a wallet imported via mnemonic or EVM private key.` + ); + } + if (wantsSolana) { const solAddress = ows.getSolAddress(lookupWallet); if (!solAddress) {