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
59 changes: 59 additions & 0 deletions cli/tests/unit/cli/utils/wallet/resolve.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
28 changes: 27 additions & 1 deletion cli/utils/wallet/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <addr> 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 <solana-pubkey> to send to a Solana wallet you control, ` +
`or --to-wallet <name> 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 <name> for a wallet imported via mnemonic or EVM private key.`
);
}

if (wantsSolana) {
const solAddress = ows.getSolAddress(lookupWallet);
if (!solAddress) {
Expand Down
Loading