Skip to content

Commit b8cd247

Browse files
authored
fix: make keys command more intuitive (#6)
- Removes `solana-import` in favour of consolidating to `add`
1 parent fa8491c commit b8cd247

8 files changed

Lines changed: 204 additions & 37 deletions

File tree

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,14 @@ curl -fsSL https://raw.githubusercontent.com/jup-ag/cli/main/scripts/install.sh
1919
## Quick Start
2020

2121
```bash
22-
# Generate a new key called 'default'
23-
jup keys add default
24-
# Or import an existing Solana CLI keypair
25-
jup keys solana-import
22+
# Generate a new private key called 'key1'
23+
jup keys add key1
24+
# Or import from a JSON file generated via `solana-keygen`
25+
jup keys add key1 --file /path/to/solana-keygen.json
26+
# Or import from a seed phrase
27+
jup keys add key1 --seed-phrase "word1 word2 ..." --derivation-path "m/44'/501'/0'/0'" # optional, defaults to "m/44'/501'/0'/0'"
28+
# Or import from a private key (accepts hex, base58, base64, or JSON byte array)
29+
jup keys add key1 --private-key <key>
2630

2731
# View your spot portfolio
2832
jup spot portfolio

bun.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/keys.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,28 @@ A key is required for signing transactions (swaps, transfers). Keys are stored l
44

55
## Commands
66

7-
### Generate a new key
7+
### Add a new key
8+
9+
Generate a new key:
810

911
```bash
1012
jup keys add <name>
1113
```
1214

13-
### Import a Solana CLI keypair
15+
Import from a JSON file generated via `solana-keygen`:
1416

1517
```bash
16-
jup keys solana-import
17-
jup keys solana-import --name mykey --path ~/.config/solana/id.json
18+
jup keys add <name> --file /path/to/solana-keygen.json
1819
```
1920

20-
### Recover from seed phrase or private key
21+
Import from private key or seed phrase:
2122

2223
```bash
23-
jup keys add <name> --recover --seed-phrase "word1 word2 ..."
24-
jup keys add <name> --recover --private-key <key>
24+
jup keys add <name> --seed-phrase "word1 word2 ..."
25+
jup keys add <name> --seed-phrase "word1 word2 ..." --derivation-path "m/44'/501'/0'/0'" # optional, defaults to "m/44'/501'/0'/0'"
26+
jup keys add <name> --private-key <key> # accepts hex, base58, base64, or a JSON byte array
2527
```
2628

27-
`--private-key` accepts hex, base58, base64, or a JSON byte array.
28-
2929
### List keys
3030

3131
```bash
@@ -36,7 +36,7 @@ jup keys list
3636
// Example JSON response:
3737
[
3838
{
39-
"name": "default",
39+
"name": "key1",
4040
"address": "ABC1...xyz", // Solana wallet address
4141
"active": true // if true, key is used by default for signing transactions
4242
}
@@ -54,6 +54,7 @@ jup keys use <name>
5454
```bash
5555
jup keys edit <name> --name <new-name>
5656
jup keys edit <name> --seed-phrase "word1 word2 ..."
57+
jup keys edit <name> --seed-phrase "word1 word2 ..." --derivation-path "m/44'/501'/0'/0'" # optional, defaults to "m/44'/501'/0'/0'"
5758
jup keys edit <name> --private-key <key>
5859
```
5960

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@jup-ag/cli",
3-
"version": "0.2.1",
3+
"version": "0.2.2",
44
"license": "GPL-3.0",
55
"type": "module",
66
"bin": {
@@ -34,7 +34,8 @@
3434
"chalk": "^5.6.2",
3535
"cli-table3": "^0.6.5",
3636
"commander": "^14.0.3",
37-
"ky": "^1.14.3"
37+
"ky": "^1.14.3",
38+
"micro-key-producer": "^0.8.5"
3839
},
3940
"devDependencies": {
4041
"@types/bun": "latest",

src/commands/KeysCommand.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Command } from "commander";
22
import {
33
copyFileSync,
44
existsSync,
5+
readFileSync,
56
readdirSync,
67
renameSync,
78
rmSync,
@@ -21,12 +22,20 @@ export class KeysCommand {
2122
.description("List all keys")
2223
.action(() => this.list());
2324
keys
24-
.command("add [name]")
25-
.description("Generate or recover a keypair")
25+
.command("add <name>")
26+
.description("Generate or import a keypair")
2627
.option("--overwrite", "Overwrite existing key")
27-
.option("--recover", "Recover from seed phrase or private key")
28-
.option("--seed-phrase <phrase>", "Seed phrase for recovery")
29-
.option("--private-key <key>", "Private key (base58) for recovery")
28+
.option("--file <path>", "Import from a JSON file")
29+
.option("--seed-phrase <phrase>", "Import from seed phrase")
30+
.option(
31+
"--derivation-path <path>",
32+
"Derivation path for seed phrase",
33+
"m/44'/501'/0'/0'"
34+
)
35+
.option(
36+
"--private-key <key>",
37+
"Import from private key (hex, base58, base64, or JSON byte array)"
38+
)
3039
.action((name, opts) => this.add(name, opts));
3140
keys
3241
.command("delete <name>")
@@ -37,6 +46,11 @@ export class KeysCommand {
3746
.description("Edit a key's name or credentials")
3847
.option("--name <new-name>", "Rename the key")
3948
.option("--seed-phrase <phrase>", "Replace key with new seed phrase")
49+
.option(
50+
"--derivation-path <path>",
51+
"Derivation path for seed phrase",
52+
"m/44'/501'/0'/0'"
53+
)
4054
.option("--private-key <key>", "Replace key with new private key")
4155
.action((name, opts) => this.edit(name, opts));
4256
keys
@@ -89,31 +103,42 @@ export class KeysCommand {
89103
}
90104

91105
private static async add(
92-
name: string = "default",
106+
name: string,
93107
opts: {
94108
overwrite?: boolean;
95-
recover?: boolean;
109+
file?: string;
96110
seedPhrase?: string;
111+
derivationPath?: string;
97112
privateKey?: string;
98113
} = {}
99114
): Promise<void> {
100115
const keyPath = join(Config.KEYS_DIR, `${name}.json`);
101-
102116
if (existsSync(keyPath) && !opts.overwrite) {
103117
throw new Error(
104118
`Key "${name}" already exists. Use --overwrite to replace.`
105119
);
106120
}
107121

122+
const importModes = [opts.file, opts.seedPhrase, opts.privateKey].filter(
123+
Boolean
124+
);
125+
if (importModes.length > 1) {
126+
throw new Error(
127+
"--file, --seed-phrase, and --private-key are mutually exclusive."
128+
);
129+
}
130+
108131
let signer: Signer;
109-
if (opts.recover) {
110-
if (opts.seedPhrase) {
111-
signer = await Signer.fromSeedPhrase(opts.seedPhrase);
112-
} else if (opts.privateKey) {
113-
signer = await Signer.fromPrivateKey(opts.privateKey);
114-
} else {
115-
throw new Error("--recover requires --seed-phrase or --private-key");
116-
}
132+
if (opts.file) {
133+
const file = readFileSync(opts.file, "utf-8");
134+
signer = await Signer.fromPrivateKey(file);
135+
} else if (opts.seedPhrase) {
136+
signer = await Signer.fromSeedPhrase(
137+
opts.seedPhrase,
138+
opts.derivationPath
139+
);
140+
} else if (opts.privateKey) {
141+
signer = await Signer.fromPrivateKey(opts.privateKey);
117142
} else {
118143
signer = await Signer.generate();
119144
}
@@ -136,6 +161,7 @@ export class KeysCommand {
136161
opts: {
137162
name?: string;
138163
seedPhrase?: string;
164+
derivationPath?: string;
139165
privateKey?: string;
140166
} = {}
141167
): Promise<void> {
@@ -157,7 +183,7 @@ export class KeysCommand {
157183

158184
if (opts.seedPhrase || opts.privateKey) {
159185
const signer = opts.seedPhrase
160-
? await Signer.fromSeedPhrase(opts.seedPhrase)
186+
? await Signer.fromSeedPhrase(opts.seedPhrase, opts.derivationPath)
161187
: await Signer.fromPrivateKey(opts.privateKey!);
162188
signer.save(name);
163189
}

src/lib/KeyPair.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import { describe, expect, test } from "bun:test";
33

44
import { KeyPair } from "./KeyPair.ts";
55

6+
// Test mnemonic from Solana cookbook:
7+
// https://solana.com/developers/cookbook/wallets/restore-from-mnemonic
8+
const TEST_MNEMONIC =
9+
"neither lonely flavor argue grass remind eye tag avocado spot unusual intact";
10+
611
describe("generate", () => {
712
test("generates a valid keypair with 24-word mnemonic", async () => {
813
const { keyPair, mnemonic } = await KeyPair.generate(24);
@@ -43,6 +48,112 @@ describe("fromSeedPhrase", () => {
4348
const kpB = await KeyPair.fromSeedPhrase(b.mnemonic);
4449
expect(kpA.publicKey).not.toEqual(kpB.publicKey);
4550
});
51+
52+
test("default derivation path matches explicit m/44'/501'/0'/0'", async () => {
53+
const { mnemonic } = await KeyPair.generate();
54+
const defaultPath = await KeyPair.fromSeedPhrase(mnemonic);
55+
const explicitPath = await KeyPair.fromSeedPhrase(
56+
mnemonic,
57+
"m/44'/501'/0'/0'"
58+
);
59+
expect(defaultPath.privateKey).toEqual(explicitPath.privateKey);
60+
expect(defaultPath.publicKey).toEqual(explicitPath.publicKey);
61+
});
62+
63+
test("custom derivation path is deterministic", async () => {
64+
const { mnemonic } = await KeyPair.generate();
65+
const path = "m/44'/501'/2'/0'";
66+
const a = await KeyPair.fromSeedPhrase(mnemonic, path);
67+
const b = await KeyPair.fromSeedPhrase(mnemonic, path);
68+
expect(a.privateKey).toEqual(b.privateKey);
69+
expect(a.publicKey).toEqual(b.publicKey);
70+
});
71+
});
72+
73+
describe("fromSeedPhrase — uses SLIP10 for all-hardened paths", () => {
74+
const toAddress = (kp: KeyPair) =>
75+
getBase58Decoder().decode(kp.publicKey);
76+
77+
test("derives correct addresses for m/44'/501'/x'/0'", async () => {
78+
for (const { index, address } of [
79+
{ index: 0, address: "5vftMkHL72JaJG6ExQfGAsT2uGVHpRR7oTNUPMs68Y2N" },
80+
{ index: 6, address: "BNMDY3tCyYbayMzBjZm8RW59unpDWcQRfVmWXCJhLb7D" },
81+
{ index: 9, address: "6frdqXQAgJMyKwmZxkLYbdGjnYTvUceh6LNhkQt2siQp" },
82+
]) {
83+
const kp = await KeyPair.fromSeedPhrase(
84+
TEST_MNEMONIC,
85+
`m/44'/501'/${index}'/0'`
86+
);
87+
expect(toAddress(kp)).toBe(address);
88+
}
89+
});
90+
91+
test("derives correct addresses for m/44'/501'/x'", async () => {
92+
for (const { index, address } of [
93+
{ index: 0, address: "ZtSqp8BQkKFvahawCS9Mf15gzFuedeWWDkYap3qQEe4" },
94+
{ index: 2, address: "7Y9pZgwuas2FbVqGKi5yPBKnxeCNxQK8EHCaS3EbXbg8" },
95+
{ index: 3, address: "7HWpiMRzpi2BQVVBWQLtSi6yYrSAEJxwAYE1jfYmytan" },
96+
]) {
97+
const kp = await KeyPair.fromSeedPhrase(
98+
TEST_MNEMONIC,
99+
`m/44'/501'/${index}'`
100+
);
101+
expect(toAddress(kp)).toBe(address);
102+
}
103+
});
104+
105+
test("different account indices derive different keypairs", async () => {
106+
const a = await KeyPair.fromSeedPhrase(
107+
TEST_MNEMONIC,
108+
"m/44'/501'/0'/0'"
109+
);
110+
const b = await KeyPair.fromSeedPhrase(
111+
TEST_MNEMONIC,
112+
"m/44'/501'/1'/0'"
113+
);
114+
expect(a.publicKey).not.toEqual(b.publicKey);
115+
expect(a.privateKey).not.toEqual(b.privateKey);
116+
});
117+
});
118+
119+
describe("fromSeedPhrase — uses BIP32 for non-hardened paths", () => {
120+
const toAddress = (kp: KeyPair) =>
121+
getBase58Decoder().decode(kp.publicKey);
122+
123+
test("derives correct address for m/44'/501'/0'/0/0", async () => {
124+
const kp = await KeyPair.fromSeedPhrase(
125+
"flee artwork post brown april bulk wash limb melody zoo rib law",
126+
"m/44'/501'/0'/0/0"
127+
);
128+
expect(toAddress(kp)).toBe(
129+
"F8oiKU5wmZs8jZQng1zyzbcHPkvECSwHitFJvuc5rQGP"
130+
);
131+
});
132+
133+
test("same non-hardened path is deterministic", async () => {
134+
const a = await KeyPair.fromSeedPhrase(
135+
TEST_MNEMONIC,
136+
"m/44'/501'/0'/0"
137+
);
138+
const b = await KeyPair.fromSeedPhrase(
139+
TEST_MNEMONIC,
140+
"m/44'/501'/0'/0"
141+
);
142+
expect(a.privateKey).toEqual(b.privateKey);
143+
expect(a.publicKey).toEqual(b.publicKey);
144+
});
145+
146+
test("trailing hardened vs non-hardened segment derive different keypairs", async () => {
147+
const hardened = await KeyPair.fromSeedPhrase(
148+
TEST_MNEMONIC,
149+
"m/44'/501'/0'/0'"
150+
);
151+
const nonHardened = await KeyPair.fromSeedPhrase(
152+
TEST_MNEMONIC,
153+
"m/44'/501'/0'/0"
154+
);
155+
expect(hardened.publicKey).not.toEqual(nonHardened.publicKey);
156+
});
46157
});
47158

48159
describe("fromPrivateKey", () => {

src/lib/KeyPair.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getBase58Encoder,
88
getBase64Encoder,
99
} from "@solana/kit";
10+
import slip10 from "micro-key-producer/slip10.js";
1011

1112
export class KeyPair {
1213
public readonly privateKey: Uint8Array;
@@ -29,9 +30,20 @@ export class KeyPair {
2930
};
3031
}
3132

32-
public static async fromSeedPhrase(phrase: string): Promise<KeyPair> {
33+
public static async fromSeedPhrase(
34+
phrase: string,
35+
derivationPath: string = "m/44'/501'/0'/0'"
36+
): Promise<KeyPair> {
3337
const seed = mnemonicToSeedSync(phrase);
34-
const hd = HDKey.fromMasterSeed(seed).derive("m/44'/501'/0'/0'");
38+
const useBip32 = derivationPath
39+
.split("/")
40+
.slice(1)
41+
.some((seg) => !seg.endsWith("'"));
42+
const hd = useBip32
43+
? // Use BIP32 for paths with any non-hardened segments (e.g. m/44'/501'/0'/0/0)
44+
HDKey.fromMasterSeed(seed).derive(derivationPath)
45+
: // Use SLIP10 for all-hardened paths (e.g. m/44'/501'/0'/0')
46+
slip10.fromMasterSeed(seed).derive(derivationPath);
3547
if (!hd.privateKey) {
3648
throw new Error("Failed to derive private key");
3749
}

0 commit comments

Comments
 (0)