gora8-signer 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,10 +33,12 @@ npm install gora8-signer
33
33
  ## Quick start — standalone signer process
34
34
 
35
35
  ```bash
36
- # Generate a key (encrypted-file store shown here; OS keychain is used
37
- # automatically instead when available — see Storage below)
36
+ # Generate both keys — EVM and Solana — together (encrypted-file store
37
+ # shown here; OS keychain is used automatically instead when available —
38
+ # see Storage below)
38
39
  export GORA8_SIGNER_KEK=$(openssl rand -hex 32) # dev only — see Storage
39
40
  npx gora8-signer init my-agent-id
41
+ # {"evmAddress":"0x...","solanaAddress":"..."}
40
42
 
41
43
  # Run the signer as its own process
42
44
  npx gora8-signer start my-agent-id
@@ -48,9 +50,16 @@ Your agent's own code then talks to it over the socket:
48
50
  import { SignerIpcClient } from "gora8-signer";
49
51
 
50
52
  const signer = new SignerIpcClient("~/.gora8/signer/my-agent-id/signer.sock");
51
- const address = await signer.address();
53
+ const evmAddress = await signer.address();
54
+ const solanaAddress = await signer.solanaAddress();
52
55
  ```
53
56
 
57
+ Both keys are entirely independent secrets — different curves
58
+ (secp256k1 vs. Ed25519), different storage entries — generated,
59
+ stored, and used the same way, on the same storage backends. An
60
+ agent that only ever transacts on one chain still gets both; the
61
+ unused one simply never gets asked to sign anything.
62
+
54
63
  ## Storage
55
64
 
56
65
  Defaults to the OS-native keychain (macOS Keychain, Windows Credential
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
- import { defaultKeyStore, EnvKekProvider } from "./keystore.js";
4
+ import { defaultKeyStore, defaultSolanaKeyStore, EnvKekProvider } from "./keystore.js";
5
5
  import { PolicyEngine } from "./policy.js";
6
6
  import { AuditLog } from "./audit-log.js";
7
7
  import { Signer, SlidingWindowRateLimiter } from "./signer.js";
@@ -30,18 +30,28 @@ async function main() {
30
30
  process.exit(1);
31
31
  }
32
32
  const dir = dataDir(agentId);
33
- const keyStore = await defaultKeyStore(agentId, join(dir, "key.enc.json"), new EnvKekProvider());
33
+ const kek = new EnvKekProvider();
34
+ const keyStore = await defaultKeyStore(agentId, join(dir, "key.enc.json"), kek);
35
+ const solanaKeyStore = await defaultSolanaKeyStore(agentId, join(dir, "solana-key.enc.json"), kek);
34
36
  const auditLog = new AuditLog(join(dir, "audit.jsonl"));
35
37
  switch (command) {
36
38
  case "init": {
37
- if (await keyStore.hasKey()) {
38
- console.error(`A key already exists for agent "${agentId}". Not generating a new one.`);
39
- const address = await keyStore.address();
40
- console.log(address);
41
- process.exit(1);
42
- }
43
- const address = await keyStore.generateAndStore();
44
- console.log(address);
39
+ // Both keys generated together, same as `gora8 deploy` provisions
40
+ // both an EVM and a Solana wallet for every agent today — two
41
+ // entirely independent secrets (different curves, different
42
+ // storage entries), not one key used two ways. Each is checked and
43
+ // reported separately so a partial prior init (e.g. EVM exists,
44
+ // Solana doesn't yet — the state every pre-Solana-self-custody
45
+ // agent is in) doesn't block generating just the missing one.
46
+ const hasEvm = await keyStore.hasKey();
47
+ const evmAddress = hasEvm ? await keyStore.address() : await keyStore.generateAndStore();
48
+ if (hasEvm)
49
+ console.error(`An EVM key already exists for agent "${agentId}". Not generating a new one.`);
50
+ const hasSolana = await solanaKeyStore.hasKey();
51
+ const solanaAddress = hasSolana ? await solanaKeyStore.address() : await solanaKeyStore.generateAndStore();
52
+ if (hasSolana)
53
+ console.error(`A Solana key already exists for agent "${agentId}". Not generating a new one.`);
54
+ console.log(JSON.stringify({ evmAddress, solanaAddress }));
45
55
  break;
46
56
  }
47
57
  case "start": {
@@ -63,6 +73,7 @@ async function main() {
63
73
  const policyEngine = new PolicyEngine({ fetchMandate: () => fetchMandate(mandateAgentId, apiBase) }, auditLog);
64
74
  const signer = new Signer({
65
75
  keyStore,
76
+ solanaKeyStore,
66
77
  policyEngine,
67
78
  auditLog,
68
79
  rateLimiter: new SlidingWindowRateLimiter(30, 60_000), // 30 signatures/min sanity backstop
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { OsKeychainStore, EncryptedFileStore, EnvKekProvider, defaultKeyStore } from "./keystore.js";
2
- export type { KeyStore, KekProvider } from "./keystore.js";
1
+ export { OsKeychainSecretStore, EncryptedFileSecretStore, EnvKekProvider, defaultKeyStore, defaultSolanaKeyStore } from "./keystore.js";
2
+ export type { KeyStore, SolanaKeyStore, SecretStore, KekProvider } from "./keystore.js";
3
3
  export { PolicyEngine } from "./policy.js";
4
4
  export type { Mandate, SigningRequest, PolicyDecision, PolicyEngineOptions, SpendTracker } from "./policy.js";
5
5
  export { AuditLog } from "./audit-log.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { OsKeychainStore, EncryptedFileStore, EnvKekProvider, defaultKeyStore } from "./keystore.js";
1
+ export { OsKeychainSecretStore, EncryptedFileSecretStore, EnvKekProvider, defaultKeyStore, defaultSolanaKeyStore } from "./keystore.js";
2
2
  export { PolicyEngine } from "./policy.js";
3
3
  export { AuditLog } from "./audit-log.js";
4
4
  export { Signer, SlidingWindowRateLimiter } from "./signer.js";
@@ -42,9 +42,20 @@ export declare class SignerIpcClient {
42
42
  to: `0x${string}`;
43
43
  value: bigint;
44
44
  data: `0x${string}`;
45
+ nonce: number;
46
+ gas?: bigint;
47
+ gasPrice?: bigint;
45
48
  };
46
49
  chainId: number;
47
50
  }): Promise<{
48
51
  signedTransaction: `0x${string}`;
49
52
  }>;
53
+ solanaAddress(): Promise<string>;
54
+ signSolanaTransaction(params: {
55
+ serializedTransaction: string;
56
+ amount: number | null;
57
+ counterparty: string | null;
58
+ }): Promise<{
59
+ signature: string;
60
+ }>;
50
61
  }
@@ -50,4 +50,10 @@ export class SignerIpcClient {
50
50
  signWithdrawal(params) {
51
51
  return this.call("signWithdrawal", params);
52
52
  }
53
+ solanaAddress() {
54
+ return this.call("solanaAddress");
55
+ }
56
+ signSolanaTransaction(params) {
57
+ return this.call("signSolanaTransaction", params);
58
+ }
53
59
  }
@@ -70,6 +70,10 @@ export class SignerIpcServer {
70
70
  return this.signer.signAgreementTerms(params);
71
71
  case "signWithdrawal":
72
72
  return this.signer.signWithdrawal(params);
73
+ case "solanaAddress":
74
+ return this.signer.solanaAddress();
75
+ case "signSolanaTransaction":
76
+ return this.signer.signSolanaTransaction(params);
73
77
  default:
74
78
  throw new Error(`Unknown method: ${method}`);
75
79
  }
@@ -1,33 +1,44 @@
1
1
  import type { Address, Hex } from "viem";
2
+ export interface SecretStore {
3
+ hasSecret(): Promise<boolean>;
4
+ /** Persists `secret` — throws if one is already stored; callers must
5
+ * not use this to overwrite an existing secret. */
6
+ store(secret: string): Promise<void>;
7
+ /** The only way to read the secret. `fn`'s return value escapes this
8
+ * scope; the raw secret string itself never does. */
9
+ withSecret<T>(fn: (secret: string) => Promise<T> | T): Promise<T>;
10
+ }
2
11
  export interface KeyStore {
3
- /** True once a key has been generated and stored — callers check this
4
- * before generateAndStore() to avoid silently overwriting an existing
5
- * key (which would orphan whatever address was already registered
6
- * on-chain). */
7
12
  hasKey(): Promise<boolean>;
8
- /** Generates a fresh key, persists it, and returns only the derived
9
- * address — never the key itself. Throws if a key already exists;
10
- * callers must not call this to "reset" a store. */
11
13
  generateAndStore(): Promise<Address>;
12
- /** The only way to use the key. `fn`'s return value escapes this
13
- * scope; the raw private key string itself never does. */
14
14
  withPrivateKey<T>(fn: (privateKey: Hex) => Promise<T> | T): Promise<T>;
15
15
  address(): Promise<Address>;
16
16
  }
17
- export declare class OsKeychainStore implements KeyStore {
17
+ export interface SolanaKeyStore {
18
+ hasKey(): Promise<boolean>;
19
+ generateAndStore(): Promise<string>;
20
+ /** `fn` receives the raw 64-byte Solana secret key, matching
21
+ * `@solana/web3.js`'s `Keypair.fromSecretKey()` input — same scoped-
22
+ * access discipline as EVM's `withPrivateKey`. */
23
+ withSecretKey<T>(fn: (secretKey: Uint8Array) => Promise<T> | T): Promise<T>;
24
+ address(): Promise<string>;
25
+ }
26
+ export declare class OsKeychainSecretStore implements SecretStore {
18
27
  private readonly service;
19
28
  private readonly account;
20
- constructor(agentId: string);
29
+ /** `account` should be unique per (agentId, key kind) pair — e.g.
30
+ * `"<agentId>"` for the EVM key, `"<agentId>:solana"` for Solana —
31
+ * so the two keys never collide in the same OS keychain. */
32
+ constructor(account: string, service?: string);
21
33
  private entry;
22
- hasKey(): Promise<boolean>;
23
- generateAndStore(): Promise<Address>;
24
- withPrivateKey<T>(fn: (privateKey: Hex) => Promise<T> | T): Promise<T>;
25
- address(): Promise<Address>;
34
+ hasSecret(): Promise<boolean>;
35
+ store(secret: string): Promise<void>;
36
+ withSecret<T>(fn: (secret: string) => Promise<T> | T): Promise<T>;
26
37
  }
27
38
  export interface KekProvider {
28
- /** Returns a 32-byte key-encryption key. Called once per
29
- * generateAndStore()/withPrivateKey() — implementations are expected
30
- * to cache/memoize their own upstream fetch if it's expensive. */
39
+ /** Returns a 32-byte key-encryption key. Called once per store()/
40
+ * withSecret() — implementations are expected to cache/memoize their
41
+ * own upstream fetch if it's expensive. */
31
42
  getKek(): Promise<Buffer>;
32
43
  }
33
44
  /** Development/local-only KEK provider — reads a hex-encoded 32-byte key
@@ -36,25 +47,26 @@ export interface KekProvider {
36
47
  * cloud account, and so self-hosted deployments have something to point
37
48
  * at before wiring up their own KMS-backed provider (see this file's
38
49
  * doc comment on KekProvider — implement one against AWS KMS/GCP KMS/
39
- * age and pass it to EncryptedFileStore instead, for anything real). */
50
+ * age and pass it to EncryptedFileSecretStore instead, for anything real). */
40
51
  export declare class EnvKekProvider implements KekProvider {
41
52
  private readonly envVar;
42
53
  constructor(envVar?: string);
43
54
  getKek(): Promise<Buffer>;
44
55
  }
45
- export declare class EncryptedFileStore implements KeyStore {
56
+ export declare class EncryptedFileSecretStore implements SecretStore {
46
57
  private readonly filePath;
47
58
  private readonly kek;
48
59
  constructor(filePath: string, kek: KekProvider);
49
- hasKey(): Promise<boolean>;
50
- generateAndStore(): Promise<Address>;
51
- withPrivateKey<T>(fn: (privateKey: Hex) => Promise<T> | T): Promise<T>;
52
- address(): Promise<Address>;
60
+ hasSecret(): Promise<boolean>;
61
+ store(secret: string): Promise<void>;
62
+ withSecret<T>(fn: (secret: string) => Promise<T> | T): Promise<T>;
53
63
  private writeEncrypted;
54
64
  private readEncrypted;
55
65
  }
56
- /** Picks OS keychain by default, falling back to the encrypted file
57
- * store only when the OS keychain genuinely isn't available (headless
58
- * container with no Secret Service, etc.) — never silently prefers the
59
- * weaker option. */
60
66
  export declare function defaultKeyStore(agentId: string, fallbackFilePath: string, kek: KekProvider): Promise<KeyStore>;
67
+ /** Same defaulting behavior as `defaultKeyStore`, for the Solana key —
68
+ * `fallbackFilePath` must be a different path than the EVM store's
69
+ * (e.g. `solana-key.enc.json` next to `key.enc.json`), and the OS
70
+ * keychain account is automatically suffixed so the two never collide
71
+ * under the same service/account pair. */
72
+ export declare function defaultSolanaKeyStore(agentId: string, fallbackFilePath: string, kek: KekProvider): Promise<SolanaKeyStore>;
package/dist/keystore.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
2
+ import { Keypair } from "@solana/web3.js";
2
3
  import { randomBytes, createCipheriv, createDecipheriv, scryptSync } from "node:crypto";
3
4
  import { readFile, writeFile, mkdir } from "node:fs/promises";
4
5
  import { dirname } from "node:path";
5
- function deriveAddress(privateKey) {
6
+ function deriveEvmAddress(privateKey) {
6
7
  return privateKeyToAccount(privateKey).address;
7
8
  }
8
9
  // ── OS-native keychain store (default) ─────────────────────────────────
@@ -13,54 +14,46 @@ function deriveAddress(privateKey) {
13
14
  // because it's the closest local equivalent to what a TEE-based vendor
14
15
  // gives you: the OS, not this process's own memory or a plain file, is
15
16
  // what actually guards the secret at rest.
16
- export class OsKeychainStore {
17
+ export class OsKeychainSecretStore {
17
18
  service;
18
19
  account;
19
- constructor(agentId) {
20
- this.service = "gora8-signer";
21
- this.account = agentId;
20
+ /** `account` should be unique per (agentId, key kind) pair — e.g.
21
+ * `"<agentId>"` for the EVM key, `"<agentId>:solana"` for Solana —
22
+ * so the two keys never collide in the same OS keychain. */
23
+ constructor(account, service = "gora8-signer") {
24
+ this.service = service;
25
+ this.account = account;
22
26
  }
23
27
  async entry() {
24
28
  const { Entry } = await import("@napi-rs/keyring");
25
29
  return new Entry(this.service, this.account);
26
30
  }
27
- async hasKey() {
31
+ async hasSecret() {
28
32
  try {
29
33
  const entry = await this.entry();
30
34
  // @napi-rs/keyring's getPassword() returns null for a missing entry
31
- // rather than throwing — treating "didn't throw" as "key exists"
32
- // (the previous implementation here) is a false positive on every
33
- // backend that behaves this way: hasKey() would report true for an
34
- // agent that has never had a key generated, and the subsequent
35
- // withPrivateKey()/address() call would then try to derive an
36
- // address from null and crash. A missing entry is only genuinely
37
- // "no key" when the returned password is a non-empty string.
35
+ // rather than throwing — treating "didn't throw" as "secret exists"
36
+ // is a false positive on every backend that behaves this way.
38
37
  return typeof entry.getPassword() === "string";
39
38
  }
40
39
  catch {
41
40
  return false;
42
41
  }
43
42
  }
44
- async generateAndStore() {
45
- if (await this.hasKey()) {
46
- throw new Error(`A key already exists for agent "${this.account}" in the OS keychain. ` +
47
- "Refusing to overwrite — generating a new key would orphan the address already registered on-chain.");
43
+ async store(secret) {
44
+ if (await this.hasSecret()) {
45
+ throw new Error(`A secret already exists for "${this.account}" in the OS keychain. Refusing to overwrite.`);
48
46
  }
49
- const privateKey = generatePrivateKey();
50
47
  const entry = await this.entry();
51
- entry.setPassword(privateKey);
52
- return deriveAddress(privateKey);
48
+ entry.setPassword(secret);
53
49
  }
54
- async withPrivateKey(fn) {
50
+ async withSecret(fn) {
55
51
  const entry = await this.entry();
56
- const privateKey = entry.getPassword();
57
- if (typeof privateKey !== "string") {
58
- throw new Error(`No key found for agent "${this.account}" in the OS keychain — run \`gora8-signer init\` first.`);
52
+ const secret = entry.getPassword();
53
+ if (typeof secret !== "string") {
54
+ throw new Error(`No secret found for "${this.account}" in the OS keychain — run \`gora8-signer init\` first.`);
59
55
  }
60
- return fn(privateKey);
61
- }
62
- async address() {
63
- return this.withPrivateKey((pk) => deriveAddress(pk));
56
+ return fn(secret);
64
57
  }
65
58
  }
66
59
  /** Development/local-only KEK provider — reads a hex-encoded 32-byte key
@@ -69,7 +62,7 @@ export class OsKeychainStore {
69
62
  * cloud account, and so self-hosted deployments have something to point
70
63
  * at before wiring up their own KMS-backed provider (see this file's
71
64
  * doc comment on KekProvider — implement one against AWS KMS/GCP KMS/
72
- * age and pass it to EncryptedFileStore instead, for anything real). */
65
+ * age and pass it to EncryptedFileSecretStore instead, for anything real). */
73
66
  export class EnvKekProvider {
74
67
  envVar;
75
68
  constructor(envVar = "GORA8_SIGNER_KEK") {
@@ -78,7 +71,7 @@ export class EnvKekProvider {
78
71
  async getKek() {
79
72
  const hex = process.env[this.envVar];
80
73
  if (!hex) {
81
- throw new Error(`${this.envVar} is not set. EncryptedFileStore needs a 32-byte hex-encoded key-encryption ` +
74
+ throw new Error(`${this.envVar} is not set. EncryptedFileSecretStore needs a 32-byte hex-encoded key-encryption ` +
82
75
  "key — generate one with `openssl rand -hex 32` for local/dev use, or implement a real " +
83
76
  "KekProvider against your own KMS for production (see keystore.ts's doc comment).");
84
77
  }
@@ -89,14 +82,14 @@ export class EnvKekProvider {
89
82
  return buf;
90
83
  }
91
84
  }
92
- export class EncryptedFileStore {
85
+ export class EncryptedFileSecretStore {
93
86
  filePath;
94
87
  kek;
95
88
  constructor(filePath, kek) {
96
89
  this.filePath = filePath;
97
90
  this.kek = kek;
98
91
  }
99
- async hasKey() {
92
+ async hasSecret() {
100
93
  try {
101
94
  await readFile(this.filePath);
102
95
  return true;
@@ -105,23 +98,17 @@ export class EncryptedFileStore {
105
98
  return false;
106
99
  }
107
100
  }
108
- async generateAndStore() {
109
- if (await this.hasKey()) {
110
- throw new Error(`A key already exists at ${this.filePath}. Refusing to overwrite — generating a new key ` +
111
- "would orphan the address already registered on-chain.");
101
+ async store(secret) {
102
+ if (await this.hasSecret()) {
103
+ throw new Error(`A secret already exists at ${this.filePath}. Refusing to overwrite.`);
112
104
  }
113
- const privateKey = generatePrivateKey();
114
- await this.writeEncrypted(privateKey);
115
- return deriveAddress(privateKey);
116
- }
117
- async withPrivateKey(fn) {
118
- const privateKey = await this.readEncrypted();
119
- return fn(privateKey);
105
+ await this.writeEncrypted(secret);
120
106
  }
121
- async address() {
122
- return this.withPrivateKey((pk) => deriveAddress(pk));
107
+ async withSecret(fn) {
108
+ const secret = await this.readEncrypted();
109
+ return fn(secret);
123
110
  }
124
- async writeEncrypted(privateKey) {
111
+ async writeEncrypted(secret) {
125
112
  const salt = randomBytes(16);
126
113
  const kekMaterial = await this.kek.getKek();
127
114
  // scrypt-derive a per-store encryption key from the raw KEK + a
@@ -131,7 +118,7 @@ export class EncryptedFileStore {
131
118
  const derivedKey = scryptSync(kekMaterial, salt, 32);
132
119
  const iv = randomBytes(12);
133
120
  const cipher = createCipheriv("aes-256-gcm", derivedKey, iv);
134
- const ciphertext = Buffer.concat([cipher.update(privateKey, "utf8"), cipher.final()]);
121
+ const ciphertext = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
135
122
  const authTag = cipher.getAuthTag();
136
123
  const payload = {
137
124
  version: 1,
@@ -157,18 +144,80 @@ export class EncryptedFileStore {
157
144
  /** Picks OS keychain by default, falling back to the encrypted file
158
145
  * store only when the OS keychain genuinely isn't available (headless
159
146
  * container with no Secret Service, etc.) — never silently prefers the
160
- * weaker option. */
161
- export async function defaultKeyStore(agentId, fallbackFilePath, kek) {
147
+ * weaker option. Shared by both EVM's and Solana's key stores below —
148
+ * `account`/`fallbackFilePath` must already be kind-specific (see
149
+ * OsKeychainSecretStore's doc comment). */
150
+ async function defaultSecretStore(account, fallbackFilePath, kek) {
162
151
  try {
163
152
  await import("@napi-rs/keyring");
164
- const store = new OsKeychainStore(agentId);
153
+ const store = new OsKeychainSecretStore(account);
165
154
  // Probe for a usable keychain backend (this throws on headless Linux
166
155
  // hosts with no Secret Service daemon running) without touching any
167
156
  // real credential.
168
- await store.hasKey();
157
+ await store.hasSecret();
169
158
  return store;
170
159
  }
171
160
  catch {
172
- return new EncryptedFileStore(fallbackFilePath, kek);
161
+ return new EncryptedFileSecretStore(fallbackFilePath, kek);
162
+ }
163
+ }
164
+ /** EVM (secp256k1) key store — the original `KeyStore`, now a thin
165
+ * curve-specific wrapper over the generic `SecretStore` above rather
166
+ * than owning its own storage logic. */
167
+ class EvmKeyStore {
168
+ store;
169
+ constructor(store) {
170
+ this.store = store;
171
+ }
172
+ hasKey() {
173
+ return this.store.hasSecret();
174
+ }
175
+ async generateAndStore() {
176
+ const privateKey = generatePrivateKey();
177
+ await this.store.store(privateKey);
178
+ return deriveEvmAddress(privateKey);
179
+ }
180
+ withPrivateKey(fn) {
181
+ return this.store.withSecret((secret) => fn(secret));
182
+ }
183
+ address() {
184
+ return this.withPrivateKey((pk) => deriveEvmAddress(pk));
185
+ }
186
+ }
187
+ /** Solana (Ed25519) key store — same storage backends as `EvmKeyStore`,
188
+ * a completely separate secret (never derived from or related to the
189
+ * EVM key), stored under its own account/file so the two can be
190
+ * generated, rotated, or lost independently. The secret key is
191
+ * persisted base64-encoded (`Keypair.secretKey` is a raw 64-byte
192
+ * buffer, not directly JSON/string-safe). */
193
+ class Ed25519SolanaKeyStore {
194
+ store;
195
+ constructor(store) {
196
+ this.store = store;
197
+ }
198
+ hasKey() {
199
+ return this.store.hasSecret();
200
+ }
201
+ async generateAndStore() {
202
+ const keypair = Keypair.generate();
203
+ await this.store.store(Buffer.from(keypair.secretKey).toString("base64"));
204
+ return keypair.publicKey.toBase58();
205
+ }
206
+ withSecretKey(fn) {
207
+ return this.store.withSecret((secret) => fn(Buffer.from(secret, "base64")));
208
+ }
209
+ address() {
210
+ return this.withSecretKey((sk) => Keypair.fromSecretKey(sk).publicKey.toBase58());
173
211
  }
174
212
  }
213
+ export async function defaultKeyStore(agentId, fallbackFilePath, kek) {
214
+ return new EvmKeyStore(await defaultSecretStore(agentId, fallbackFilePath, kek));
215
+ }
216
+ /** Same defaulting behavior as `defaultKeyStore`, for the Solana key —
217
+ * `fallbackFilePath` must be a different path than the EVM store's
218
+ * (e.g. `solana-key.enc.json` next to `key.enc.json`), and the OS
219
+ * keychain account is automatically suffixed so the two never collide
220
+ * under the same service/account pair. */
221
+ export async function defaultSolanaKeyStore(agentId, fallbackFilePath, kek) {
222
+ return new Ed25519SolanaKeyStore(await defaultSecretStore(`${agentId}:solana`, fallbackFilePath, kek));
223
+ }
package/dist/policy.d.ts CHANGED
@@ -19,7 +19,7 @@ export interface SigningRequest {
19
19
  * operations that don't move value (identity registration, the
20
20
  * EIP-7702 delegation itself). */
21
21
  amount: number | null;
22
- counterparty: Address | null;
22
+ counterparty: string | null;
23
23
  /** Contract + 4-byte selector, for a MandateEnforcer.execute()-shaped
24
24
  * call — checked against an allowlist if one is configured (see
25
25
  * PolicyEngine's constructor). Mandate.spending/counterpartyRequirements
package/dist/signer.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Address, type Hex } from "viem";
2
- import type { KeyStore } from "./keystore.js";
2
+ import type { KeyStore, SolanaKeyStore } from "./keystore.js";
3
3
  import type { PolicyEngine } from "./policy.js";
4
4
  import { AuditLog } from "./audit-log.js";
5
5
  interface RateLimiter {
@@ -22,11 +22,14 @@ export interface SignerOptions {
22
22
  policyEngine: PolicyEngine;
23
23
  auditLog: AuditLog;
24
24
  rateLimiter?: RateLimiter;
25
+ solanaKeyStore?: SolanaKeyStore;
25
26
  }
26
27
  export declare class Signer {
27
28
  private readonly options;
28
29
  constructor(options: SignerOptions);
29
30
  address(): Promise<Address>;
31
+ private requireSolanaKeyStore;
32
+ solanaAddress(): Promise<string>;
30
33
  private logAndEnforce;
31
34
  /** One-time: signs the register() call that mints this agent's
32
35
  * ERC-8004 identity, owned by this signer's own address. Takes an
@@ -36,7 +39,22 @@ export declare class Signer {
36
39
  * already exists in erc8004-client.ts's server-side counterpart) and
37
40
  * to keep this signer's own network surface at zero: it never makes
38
41
  * an RPC call, only signs exactly what it's handed. `agentURI` is
39
- * accepted for the audit log entry, not used to build the call. */
42
+ * accepted for the audit log entry, not used to build the call.
43
+ *
44
+ * Signs via `account.signTransaction()` directly, not a WalletClient —
45
+ * a real bug, found live: `createWalletClient(...).signTransaction()`
46
+ * silently calls its transport for `eth_chainId` (and, once that's
47
+ * reachable, fee-market data) whenever the tx is missing a `type` or
48
+ * fee fields, which the offline placeholder above can never answer —
49
+ * contradicting this method's own "never makes an RPC call" claim.
50
+ * `viem/accounts`' local account type has no such dependency; the
51
+ * trade-off is that a caller-omitted fee/gas field now needs an
52
+ * explicit local default (below) instead of network-derived current
53
+ * values — correct for keeping this signer's network surface at
54
+ * zero, but the caller (erc8004-client.ts) supplying real values,
55
+ * which it already has the RPC access to compute, remains the better
56
+ * long-term fix; these defaults are a safe fallback, not a reason not
57
+ * to. */
40
58
  signRegisterIdentity(params: {
41
59
  agentURI: string;
42
60
  unsignedTx: {
@@ -44,6 +62,7 @@ export declare class Signer {
44
62
  nonce: number;
45
63
  data: Hex;
46
64
  gas?: bigint;
65
+ gasPrice?: bigint;
47
66
  };
48
67
  chainId: number;
49
68
  }): Promise<{
@@ -103,7 +122,22 @@ export declare class Signer {
103
122
  }>;
104
123
  /** Owner-initiated funds-out — same policy checks apply (a withdrawal
105
124
  * is still constrained by the Mandate's spend limits and, if
106
- * configured, allowed-counterparty list). */
125
+ * configured, allowed-counterparty list).
126
+ *
127
+ * Checked against the actual caller before fixing, not assumed: as of
128
+ * this writing, gora8's own server never sends this operation —
129
+ * services/payment/onchain.ts's withdrawEvm() routes a withdrawal
130
+ * through executeViaMandate() (signMandateExecute, unaffected by this
131
+ * bug) instead, since a withdrawal is exactly the kind of spend
132
+ * Authority already has to constrain. This method is nonetheless real
133
+ * public API surface (agent-ts's sign-handler.ts routes to it) a
134
+ * third-party integration could call directly, so it gets the same
135
+ * fix as signRegisterIdentity above rather than being left broken
136
+ * because gora8's own server happens not to exercise it. Adds a
137
+ * required `nonce` (this method cannot produce a valid signed
138
+ * transaction without one, whichever caller eventually supplies it)
139
+ * and optional gas/fee fields, same shape and defaulting as
140
+ * signRegisterIdentity. */
107
141
  signWithdrawal(params: {
108
142
  to: Address;
109
143
  amount: number;
@@ -111,10 +145,36 @@ export declare class Signer {
111
145
  to: Address;
112
146
  value: bigint;
113
147
  data: Hex;
148
+ nonce: number;
149
+ gas?: bigint;
150
+ gasPrice?: bigint;
114
151
  };
115
152
  chainId: number;
116
153
  }): Promise<{
117
154
  signedTransaction: Hex;
118
155
  }>;
156
+ /** Signs this agent's own required-signer slot in a pre-built Solana
157
+ * transaction — the same "server builds it, signer only authorizes"
158
+ * division as the EVM Mandate-execute path (signMandateExecute
159
+ * above). The caller (gora8's server) already set the transaction's
160
+ * recent blockhash and fee payer (gora8's own fee-payer wallet, a
161
+ * separate signer this package never touches) before serializing it
162
+ * with `requireAllSignatures: false` — so signing here needs no RPC
163
+ * call either, the same zero-network-surface property every other
164
+ * method in this file holds to (unlike the EVM signTransaction bug
165
+ * fixed elsewhere in this file, Solana's `Transaction.partialSign()`
166
+ * has no such hidden network dependency — verified by construction:
167
+ * it operates purely on the already-deserialized message). Returns
168
+ * only this signer's own signature, base64-encoded; the caller
169
+ * merges it into the full transaction and adds the fee payer's
170
+ * signature before broadcasting — this package still never
171
+ * broadcasts anything, same discipline as the EVM side. */
172
+ signSolanaTransaction(params: {
173
+ serializedTransaction: string;
174
+ amount: number | null;
175
+ counterparty: string | null;
176
+ }): Promise<{
177
+ signature: string;
178
+ }>;
119
179
  }
120
180
  export {};
package/dist/signer.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createWalletClient, http, defineChain } from "viem";
2
+ import { Transaction, Keypair } from "@solana/web3.js";
2
3
  /** Simple sliding-window limiter — no external dependency needed for
3
4
  * something this small. */
4
5
  export class SlidingWindowRateLimiter {
@@ -26,6 +27,17 @@ export class SlidingWindowRateLimiter {
26
27
  // access is needed or granted here; broadcasting is gora8's relay
27
28
  // wallet's job, not this signer's — see this file's own doc comment.
28
29
  const OFFLINE_PLACEHOLDER_RPC_URL = "http://127.0.0.1:0";
30
+ // Safe, deliberately generous offline defaults for a legacy-type
31
+ // transaction whose caller didn't supply gas/fee fields — a real
32
+ // per-chain current price would be lower on every chain this signer
33
+ // targets today (all low-fee L2s), so overpaying trivially here is the
34
+ // safe failure direction; underpricing (or a gas limit too low for a
35
+ // contract call to complete) is the unsafe one. Deliberately not
36
+ // fetched from a real RPC, per this file's zero-network-surface design —
37
+ // see signRegisterIdentity's doc comment for why a caller-supplied real
38
+ // value should be preferred once available.
39
+ const DEFAULT_GAS_PRICE = 1000000000n; // 1 gwei
40
+ const DEFAULT_GAS_LIMIT = 300000n; // generous for a simple contract call (e.g. ERC-8004 register())
29
41
  function genericChain(chainId) {
30
42
  return defineChain({
31
43
  id: chainId,
@@ -42,6 +54,15 @@ export class Signer {
42
54
  async address() {
43
55
  return this.options.keyStore.address();
44
56
  }
57
+ requireSolanaKeyStore() {
58
+ if (!this.options.solanaKeyStore) {
59
+ throw new Error("No Solana key configured for this signer — run `gora8-signer init <agent-id>` with Solana support, or pass solanaKeyStore explicitly.");
60
+ }
61
+ return this.options.solanaKeyStore;
62
+ }
63
+ async solanaAddress() {
64
+ return this.requireSolanaKeyStore().address();
65
+ }
45
66
  async logAndEnforce(operation, request) {
46
67
  this.options.rateLimiter?.checkAndRecord();
47
68
  const decision = await this.options.policyEngine.evaluate(request);
@@ -66,20 +87,35 @@ export class Signer {
66
87
  * already exists in erc8004-client.ts's server-side counterpart) and
67
88
  * to keep this signer's own network surface at zero: it never makes
68
89
  * an RPC call, only signs exactly what it's handed. `agentURI` is
69
- * accepted for the audit log entry, not used to build the call. */
90
+ * accepted for the audit log entry, not used to build the call.
91
+ *
92
+ * Signs via `account.signTransaction()` directly, not a WalletClient —
93
+ * a real bug, found live: `createWalletClient(...).signTransaction()`
94
+ * silently calls its transport for `eth_chainId` (and, once that's
95
+ * reachable, fee-market data) whenever the tx is missing a `type` or
96
+ * fee fields, which the offline placeholder above can never answer —
97
+ * contradicting this method's own "never makes an RPC call" claim.
98
+ * `viem/accounts`' local account type has no such dependency; the
99
+ * trade-off is that a caller-omitted fee/gas field now needs an
100
+ * explicit local default (below) instead of network-derived current
101
+ * values — correct for keeping this signer's network surface at
102
+ * zero, but the caller (erc8004-client.ts) supplying real values,
103
+ * which it already has the RPC access to compute, remains the better
104
+ * long-term fix; these defaults are a safe fallback, not a reason not
105
+ * to. */
70
106
  async signRegisterIdentity(params) {
71
107
  await this.logAndEnforce("signRegisterIdentity", { amount: null, counterparty: null, contract: null, selector: null });
72
108
  return this.options.keyStore.withPrivateKey(async (privateKey) => {
73
109
  const { privateKeyToAccount } = await import("viem/accounts");
74
110
  const account = privateKeyToAccount(privateKey);
75
- const walletClient = createWalletClient({ account, chain: genericChain(params.chainId), transport: http(OFFLINE_PLACEHOLDER_RPC_URL) });
76
- const signedTransaction = await walletClient.signTransaction({
77
- account,
78
- chain: genericChain(params.chainId),
111
+ const signedTransaction = await account.signTransaction({
112
+ type: "legacy",
113
+ chainId: params.chainId,
79
114
  to: params.unsignedTx.to,
80
115
  nonce: params.unsignedTx.nonce,
81
116
  data: params.unsignedTx.data,
82
- gas: params.unsignedTx.gas,
117
+ gas: params.unsignedTx.gas ?? DEFAULT_GAS_LIMIT,
118
+ gasPrice: params.unsignedTx.gasPrice ?? DEFAULT_GAS_PRICE,
83
119
  });
84
120
  return { signedTransaction };
85
121
  });
@@ -152,21 +188,68 @@ export class Signer {
152
188
  }
153
189
  /** Owner-initiated funds-out — same policy checks apply (a withdrawal
154
190
  * is still constrained by the Mandate's spend limits and, if
155
- * configured, allowed-counterparty list). */
191
+ * configured, allowed-counterparty list).
192
+ *
193
+ * Checked against the actual caller before fixing, not assumed: as of
194
+ * this writing, gora8's own server never sends this operation —
195
+ * services/payment/onchain.ts's withdrawEvm() routes a withdrawal
196
+ * through executeViaMandate() (signMandateExecute, unaffected by this
197
+ * bug) instead, since a withdrawal is exactly the kind of spend
198
+ * Authority already has to constrain. This method is nonetheless real
199
+ * public API surface (agent-ts's sign-handler.ts routes to it) a
200
+ * third-party integration could call directly, so it gets the same
201
+ * fix as signRegisterIdentity above rather than being left broken
202
+ * because gora8's own server happens not to exercise it. Adds a
203
+ * required `nonce` (this method cannot produce a valid signed
204
+ * transaction without one, whichever caller eventually supplies it)
205
+ * and optional gas/fee fields, same shape and defaulting as
206
+ * signRegisterIdentity. */
156
207
  async signWithdrawal(params) {
157
208
  await this.logAndEnforce("signWithdrawal", { amount: params.amount, counterparty: params.to, contract: null, selector: null });
158
209
  return this.options.keyStore.withPrivateKey(async (privateKey) => {
159
210
  const { privateKeyToAccount } = await import("viem/accounts");
160
211
  const account = privateKeyToAccount(privateKey);
161
- const walletClient = createWalletClient({ account, chain: genericChain(params.chainId), transport: http(OFFLINE_PLACEHOLDER_RPC_URL) });
162
- const signedTransaction = await walletClient.signTransaction({
163
- account,
212
+ const signedTransaction = await account.signTransaction({
213
+ type: "legacy",
214
+ chainId: params.chainId,
164
215
  to: params.unsignedTx.to,
165
216
  value: params.unsignedTx.value,
166
217
  data: params.unsignedTx.data,
167
- chain: genericChain(params.chainId),
218
+ nonce: params.unsignedTx.nonce,
219
+ gas: params.unsignedTx.gas ?? DEFAULT_GAS_LIMIT,
220
+ gasPrice: params.unsignedTx.gasPrice ?? DEFAULT_GAS_PRICE,
168
221
  });
169
222
  return { signedTransaction };
170
223
  });
171
224
  }
225
+ /** Signs this agent's own required-signer slot in a pre-built Solana
226
+ * transaction — the same "server builds it, signer only authorizes"
227
+ * division as the EVM Mandate-execute path (signMandateExecute
228
+ * above). The caller (gora8's server) already set the transaction's
229
+ * recent blockhash and fee payer (gora8's own fee-payer wallet, a
230
+ * separate signer this package never touches) before serializing it
231
+ * with `requireAllSignatures: false` — so signing here needs no RPC
232
+ * call either, the same zero-network-surface property every other
233
+ * method in this file holds to (unlike the EVM signTransaction bug
234
+ * fixed elsewhere in this file, Solana's `Transaction.partialSign()`
235
+ * has no such hidden network dependency — verified by construction:
236
+ * it operates purely on the already-deserialized message). Returns
237
+ * only this signer's own signature, base64-encoded; the caller
238
+ * merges it into the full transaction and adds the fee payer's
239
+ * signature before broadcasting — this package still never
240
+ * broadcasts anything, same discipline as the EVM side. */
241
+ async signSolanaTransaction(params) {
242
+ await this.logAndEnforce("signSolanaTransaction", { amount: params.amount, counterparty: params.counterparty, contract: null, selector: null });
243
+ const solanaKeyStore = this.requireSolanaKeyStore();
244
+ return solanaKeyStore.withSecretKey(async (secretKey) => {
245
+ const keypair = Keypair.fromSecretKey(secretKey);
246
+ const transaction = Transaction.from(Buffer.from(params.serializedTransaction, "base64"));
247
+ transaction.partialSign(keypair);
248
+ const entry = transaction.signatures.find((s) => s.publicKey.equals(keypair.publicKey));
249
+ if (!entry?.signature) {
250
+ throw new Error("Signing produced no signature for this agent's Solana public key — the transaction doesn't name it as a required signer.");
251
+ }
252
+ return { signature: Buffer.from(entry.signature).toString("base64") };
253
+ });
254
+ }
172
255
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gora8-signer",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Local, self-custodied key signing for gora8 agents — the key is generated and used wherever your agent runs, never inside gora8's own infrastructure. See SELF_CUSTODY_ARCHITECTURE.md in the gora8 monorepo for the full design.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,7 +15,11 @@
15
15
  "bin": {
16
16
  "gora8-signer": "./dist/cli.js"
17
17
  },
18
- "files": ["dist", "README.md", "LICENSE"],
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
19
23
  "scripts": {
20
24
  "build": "tsc",
21
25
  "prepublishOnly": "npm run build"
@@ -24,11 +28,12 @@
24
28
  "node": ">=18"
25
29
  },
26
30
  "devDependencies": {
27
- "typescript": "^5.6.0",
28
- "@types/node": "^20.0.0"
31
+ "@types/node": "^20.0.0",
32
+ "typescript": "^5.6.0"
29
33
  },
30
34
  "dependencies": {
31
- "viem": "^2.21.0",
32
- "@napi-rs/keyring": "^1.3.0"
35
+ "@napi-rs/keyring": "^1.3.0",
36
+ "@solana/web3.js": "^1.98.4",
37
+ "viem": "^2.21.0"
33
38
  }
34
39
  }