@agirails/sdk 2.2.2 → 2.3.0

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.
Files changed (97) hide show
  1. package/README.md +20 -23
  2. package/dist/ACTPClient.d.ts +7 -0
  3. package/dist/ACTPClient.d.ts.map +1 -1
  4. package/dist/ACTPClient.js +56 -1
  5. package/dist/ACTPClient.js.map +1 -1
  6. package/dist/abi/AgentRegistry.json +133 -0
  7. package/dist/adapters/X402Adapter.d.ts +34 -7
  8. package/dist/adapters/X402Adapter.d.ts.map +1 -1
  9. package/dist/adapters/X402Adapter.js +36 -8
  10. package/dist/adapters/X402Adapter.js.map +1 -1
  11. package/dist/adapters/index.d.ts +1 -1
  12. package/dist/adapters/index.d.ts.map +1 -1
  13. package/dist/adapters/index.js.map +1 -1
  14. package/dist/cli/commands/diff.d.ts +11 -0
  15. package/dist/cli/commands/diff.d.ts.map +1 -0
  16. package/dist/cli/commands/diff.js +115 -0
  17. package/dist/cli/commands/diff.js.map +1 -0
  18. package/dist/cli/commands/init.d.ts.map +1 -1
  19. package/dist/cli/commands/init.js +51 -2
  20. package/dist/cli/commands/init.js.map +1 -1
  21. package/dist/cli/commands/publish.d.ts +11 -0
  22. package/dist/cli/commands/publish.d.ts.map +1 -0
  23. package/dist/cli/commands/publish.js +170 -0
  24. package/dist/cli/commands/publish.js.map +1 -0
  25. package/dist/cli/commands/pull.d.ts +12 -0
  26. package/dist/cli/commands/pull.d.ts.map +1 -0
  27. package/dist/cli/commands/pull.js +99 -0
  28. package/dist/cli/commands/pull.js.map +1 -0
  29. package/dist/cli/index.js +7 -0
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/config/agirailsmd.d.ts +94 -0
  32. package/dist/config/agirailsmd.d.ts.map +1 -0
  33. package/dist/config/agirailsmd.js +209 -0
  34. package/dist/config/agirailsmd.js.map +1 -0
  35. package/dist/config/networks.d.ts +2 -0
  36. package/dist/config/networks.d.ts.map +1 -1
  37. package/dist/config/networks.js +10 -4
  38. package/dist/config/networks.js.map +1 -1
  39. package/dist/config/publishPipeline.d.ts +61 -0
  40. package/dist/config/publishPipeline.d.ts.map +1 -0
  41. package/dist/config/publishPipeline.js +192 -0
  42. package/dist/config/publishPipeline.js.map +1 -0
  43. package/dist/config/syncOperations.d.ts +67 -0
  44. package/dist/config/syncOperations.d.ts.map +1 -0
  45. package/dist/config/syncOperations.js +208 -0
  46. package/dist/config/syncOperations.js.map +1 -0
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +7 -3
  50. package/dist/index.js.map +1 -1
  51. package/dist/level0/request.d.ts.map +1 -1
  52. package/dist/level0/request.js +23 -86
  53. package/dist/level0/request.js.map +1 -1
  54. package/dist/level1/Agent.d.ts +0 -11
  55. package/dist/level1/Agent.d.ts.map +1 -1
  56. package/dist/level1/Agent.js +15 -32
  57. package/dist/level1/Agent.js.map +1 -1
  58. package/dist/registry/AgentRegistryClient.d.ts +75 -0
  59. package/dist/registry/AgentRegistryClient.d.ts.map +1 -0
  60. package/dist/registry/AgentRegistryClient.js +160 -0
  61. package/dist/registry/AgentRegistryClient.js.map +1 -0
  62. package/dist/runtime/MockRuntime.d.ts.map +1 -1
  63. package/dist/runtime/MockRuntime.js +3 -1
  64. package/dist/runtime/MockRuntime.js.map +1 -1
  65. package/dist/types/adapter.d.ts +39 -0
  66. package/dist/types/adapter.d.ts.map +1 -1
  67. package/dist/types/adapter.js +7 -0
  68. package/dist/types/adapter.js.map +1 -1
  69. package/dist/types/x402.d.ts +23 -0
  70. package/dist/types/x402.d.ts.map +1 -1
  71. package/dist/types/x402.js.map +1 -1
  72. package/dist/wallet/keystore.d.ts +16 -0
  73. package/dist/wallet/keystore.d.ts.map +1 -0
  74. package/dist/wallet/keystore.js +132 -0
  75. package/dist/wallet/keystore.js.map +1 -0
  76. package/package.json +2 -1
  77. package/src/ACTPClient.ts +63 -1
  78. package/src/abi/AgentRegistry.json +133 -0
  79. package/src/adapters/X402Adapter.ts +94 -16
  80. package/src/adapters/index.ts +9 -1
  81. package/src/cli/commands/diff.ts +141 -0
  82. package/src/cli/commands/init.ts +65 -4
  83. package/src/cli/commands/publish.ts +209 -0
  84. package/src/cli/commands/pull.ts +124 -0
  85. package/src/cli/index.ts +8 -0
  86. package/src/config/agirailsmd.ts +262 -0
  87. package/src/config/networks.ts +12 -4
  88. package/src/config/publishPipeline.ts +276 -0
  89. package/src/config/syncOperations.ts +279 -0
  90. package/src/index.ts +3 -0
  91. package/src/level0/request.ts +27 -88
  92. package/src/level1/Agent.ts +16 -32
  93. package/src/registry/AgentRegistryClient.ts +202 -0
  94. package/src/runtime/MockRuntime.ts +3 -1
  95. package/src/types/adapter.ts +14 -0
  96. package/src/types/x402.ts +32 -0
  97. package/src/wallet/keystore.ts +119 -0
@@ -13,6 +13,7 @@ import * as os from 'os';
13
13
  import * as fs from 'fs';
14
14
  import { ethers } from 'ethers';
15
15
  import { ACTPClient } from '../ACTPClient';
16
+ import { resolvePrivateKey } from '../wallet/keystore';
16
17
  import { Job, JobHandler, JobContext } from './types/Job';
17
18
  import { RequestOptions, RequestResult, NetworkOption } from './types/Options';
18
19
  import { PricingStrategy } from './pricing/PricingStrategy';
@@ -386,12 +387,8 @@ export class Agent extends EventEmitter {
386
387
  this.emit('starting');
387
388
 
388
389
  try {
389
- // SECURITY FIX (RPCURL): Use rpcUrl from config or fallback to network default
390
- // This allows Agent to work with testnet/mainnet without requiring explicit rpcUrl
391
- // if user is okay with public RPC endpoints.
392
390
  let rpcUrl = this.config.rpcUrl;
393
391
  if (!rpcUrl && (this.network === 'testnet' || this.network === 'mainnet')) {
394
- // Import getNetwork to get default rpcUrl from network config
395
392
  const { getNetwork } = await import('../config/networks');
396
393
  const networkName = this.network === 'testnet' ? 'base-sepolia' : 'base-mainnet';
397
394
  const networkConfig = getNetwork(networkName);
@@ -399,16 +396,14 @@ export class Agent extends EventEmitter {
399
396
  this.logger.info(`Using default RPC URL for ${networkName}: ${rpcUrl}`);
400
397
  }
401
398
 
402
- // Initialize ACTP client
403
399
  this._client = await ACTPClient.create({
404
400
  mode: this.network === 'testnet' ? 'testnet' : this.network === 'mainnet' ? 'mainnet' : 'mock',
405
- requesterAddress: this.address || this.generateAddress(),
401
+ requesterAddress: this.address || await this.generateAddress(),
406
402
  stateDirectory: this.config.stateDirectory,
407
- privateKey: this.getPrivateKey(),
403
+ privateKey: await this.getPrivateKey(),
408
404
  rpcUrl,
409
405
  });
410
406
 
411
- // Start polling for jobs
412
407
  this.startPolling();
413
408
 
414
409
  this._status = 'running';
@@ -1371,15 +1366,8 @@ export class Agent extends EventEmitter {
1371
1366
  }
1372
1367
  }
1373
1368
 
1374
- /**
1375
- * Generate address based on wallet configuration
1376
- *
1377
- * SECURITY FIX (HIGH): For testnet/mainnet, MUST derive from private key.
1378
- * For mock mode, can use deterministic address for convenience.
1379
- */
1380
- private generateAddress(): string {
1381
- // If wallet has private key, ALWAYS derive address from it
1382
- const privateKey = this.getPrivateKey();
1369
+ private async generateAddress(): Promise<string> {
1370
+ const privateKey = await this.getPrivateKey();
1383
1371
  if (privateKey) {
1384
1372
  try {
1385
1373
  const wallet = new ethers.Wallet(privateKey);
@@ -1389,33 +1377,31 @@ export class Agent extends EventEmitter {
1389
1377
  }
1390
1378
  }
1391
1379
 
1392
- // For non-mock networks, require a valid private key or address
1393
1380
  if (this.network === 'testnet' || this.network === 'mainnet') {
1394
1381
  throw new ValidationError(
1395
1382
  'wallet',
1396
- `${this.network} mode requires a valid private key or address in wallet configuration`
1383
+ `${this.network} mode requires a valid private key or address in wallet configuration.\n` +
1384
+ 'Run "actp init" to generate a keystore, or set ACTP_PRIVATE_KEY env var.'
1397
1385
  );
1398
1386
  }
1399
1387
 
1400
- // For mock mode only: generate deterministic address from agent name
1401
- // This is safe because mock mode doesn't involve real funds
1402
1388
  return `0x${Buffer.from(this.name).toString('hex').padEnd(40, '0').slice(0, 40)}`;
1403
1389
  }
1404
1390
 
1405
- /**
1406
- * Get private key from configuration
1407
- *
1408
- * SECURITY FIX (HIGH): Validate private key format before use
1409
- */
1410
- private getPrivateKey(): string | undefined {
1411
- if (!this.config.wallet || this.config.wallet === 'auto' || this.config.wallet === 'connect') {
1391
+ private async getPrivateKey(): Promise<string | undefined> {
1392
+ if (!this.config.wallet || this.config.wallet === 'auto') {
1393
+ if (this.network === 'testnet' || this.network === 'mainnet') {
1394
+ return resolvePrivateKey(this.config.stateDirectory);
1395
+ }
1396
+ return undefined;
1397
+ }
1398
+
1399
+ if (this.config.wallet === 'connect') {
1412
1400
  return undefined;
1413
1401
  }
1414
1402
 
1415
1403
  if (typeof this.config.wallet === 'string') {
1416
- // Check if it looks like a private key (0x + 64 hex chars)
1417
1404
  if (/^0x[0-9a-fA-F]{64}$/.test(this.config.wallet)) {
1418
- // Validate by trying to create a wallet
1419
1405
  try {
1420
1406
  new ethers.Wallet(this.config.wallet);
1421
1407
  return this.config.wallet;
@@ -1423,11 +1409,9 @@ export class Agent extends EventEmitter {
1423
1409
  throw new ValidationError('wallet', 'Invalid private key format');
1424
1410
  }
1425
1411
  }
1426
- // It's an address, not a private key
1427
1412
  return undefined;
1428
1413
  }
1429
1414
 
1430
- // Validate private key format
1431
1415
  if (this.config.wallet.privateKey) {
1432
1416
  try {
1433
1417
  new ethers.Wallet(this.config.wallet.privateKey);
@@ -0,0 +1,202 @@
1
+ /**
2
+ * AgentRegistryClient - Thin wrapper for AgentRegistry v2 config operations
3
+ *
4
+ * Provides methods for:
5
+ * - Publishing AGIRAILS.md config (CID + hash) on-chain
6
+ * - Managing launchpad listing visibility
7
+ * - Reading config state for any agent
8
+ *
9
+ * @module registry/AgentRegistryClient
10
+ */
11
+
12
+ import { Contract, Signer, Provider } from 'ethers';
13
+ import AgentRegistryABI from '../abi/AgentRegistry.json';
14
+ import { ValidationError, TransactionRevertedError } from '../errors';
15
+
16
+ // ============================================================================
17
+ // Types
18
+ // ============================================================================
19
+
20
+ export interface AgentConfigState {
21
+ configHash: string;
22
+ configCID: string;
23
+ listed: boolean;
24
+ isActive: boolean;
25
+ updatedAt: number;
26
+ }
27
+
28
+ export interface PublishConfigResult {
29
+ txHash: string;
30
+ }
31
+
32
+ interface GasOptions {
33
+ maxFeePerGas?: bigint;
34
+ maxPriorityFeePerGas?: bigint;
35
+ }
36
+
37
+ // ============================================================================
38
+ // Client
39
+ // ============================================================================
40
+
41
+ export class AgentRegistryClient {
42
+ private contract: Contract;
43
+ private readonlyContract: Contract;
44
+
45
+ constructor(
46
+ private readonly registryAddress: string,
47
+ private readonly signer: Signer,
48
+ private readonly gasSettings?: GasOptions
49
+ ) {
50
+ this.contract = new Contract(registryAddress, AgentRegistryABI, signer);
51
+ this.readonlyContract = this.contract;
52
+ }
53
+
54
+ /**
55
+ * Create a read-only client (no signer required)
56
+ */
57
+ static readOnly(registryAddress: string, provider: Provider): AgentRegistryClient {
58
+ // Create a minimal signer-like object for the contract
59
+ // but only the readonly contract will be used
60
+ const contract = new Contract(registryAddress, AgentRegistryABI, provider);
61
+ const client = Object.create(AgentRegistryClient.prototype);
62
+ client.registryAddress = registryAddress;
63
+ client.readonlyContract = contract;
64
+ client.contract = null; // Write operations will throw
65
+ return client;
66
+ }
67
+
68
+ // ========== WRITE OPERATIONS ==========
69
+
70
+ /**
71
+ * Publish config (AGIRAILS.md) on-chain
72
+ *
73
+ * @param cid - IPFS CID pointing to the AGIRAILS.md file
74
+ * @param hash - keccak256 of canonical AGIRAILS.md content (bytes32)
75
+ * @returns Transaction hash
76
+ */
77
+ async publishConfig(cid: string, hash: string): Promise<PublishConfigResult> {
78
+ if (!this.contract) {
79
+ throw new Error('Write operations require a signer. Use AgentRegistryClient constructor, not readOnly().');
80
+ }
81
+
82
+ if (!cid || cid.length === 0) {
83
+ throw new ValidationError('cid', 'IPFS CID is required');
84
+ }
85
+ if (cid.length > 128) {
86
+ throw new ValidationError('cid', 'CID too long (max 128 characters)');
87
+ }
88
+ if (!hash || hash === '0x' + '0'.repeat(64)) {
89
+ throw new ValidationError('hash', 'Config hash is required (cannot be zero)');
90
+ }
91
+ if (!/^0x[a-fA-F0-9]{64}$/.test(hash)) {
92
+ throw new ValidationError('hash', 'Config hash must be a valid bytes32 hex string');
93
+ }
94
+
95
+ try {
96
+ const estimatedGas = await this.contract.publishConfig.estimateGas(cid, hash);
97
+ const gasLimit = (estimatedGas * 120n) / 100n; // 20% buffer
98
+
99
+ const txOptions: Record<string, unknown> = { gasLimit };
100
+ if (this.gasSettings?.maxFeePerGas) {
101
+ txOptions.maxFeePerGas = this.gasSettings.maxFeePerGas;
102
+ }
103
+ if (this.gasSettings?.maxPriorityFeePerGas) {
104
+ txOptions.maxPriorityFeePerGas = this.gasSettings.maxPriorityFeePerGas;
105
+ }
106
+
107
+ const tx = await this.contract.publishConfig(cid, hash, txOptions);
108
+ const receipt = await tx.wait();
109
+
110
+ return { txHash: receipt.hash };
111
+ } catch (error: unknown) {
112
+ if (error instanceof Error && error.message.includes('Not registered')) {
113
+ throw new TransactionRevertedError(
114
+ 'Agent not registered. Register first using the AgentRegistry before publishing config.',
115
+ 'publishConfig'
116
+ );
117
+ }
118
+ throw error;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Set launchpad listing visibility
124
+ *
125
+ * @param listed - Whether agent should be visible on launchpad
126
+ * @returns Transaction hash
127
+ */
128
+ async setListed(listed: boolean): Promise<string> {
129
+ if (!this.contract) {
130
+ throw new Error('Write operations require a signer. Use AgentRegistryClient constructor, not readOnly().');
131
+ }
132
+
133
+ try {
134
+ const estimatedGas = await this.contract.setListed.estimateGas(listed);
135
+ const gasLimit = (estimatedGas * 115n) / 100n; // 15% buffer
136
+
137
+ const txOptions: Record<string, unknown> = { gasLimit };
138
+ if (this.gasSettings?.maxFeePerGas) {
139
+ txOptions.maxFeePerGas = this.gasSettings.maxFeePerGas;
140
+ }
141
+ if (this.gasSettings?.maxPriorityFeePerGas) {
142
+ txOptions.maxPriorityFeePerGas = this.gasSettings.maxPriorityFeePerGas;
143
+ }
144
+
145
+ const tx = await this.contract.setListed(listed, txOptions);
146
+ const receipt = await tx.wait();
147
+
148
+ return receipt.hash;
149
+ } catch (error: unknown) {
150
+ if (error instanceof Error && error.message.includes('Not registered')) {
151
+ throw new TransactionRevertedError(
152
+ 'Agent not registered. Register first before setting listing status.',
153
+ 'setListed'
154
+ );
155
+ }
156
+ throw error;
157
+ }
158
+ }
159
+
160
+ // ========== READ OPERATIONS ==========
161
+
162
+ /**
163
+ * Get config state for an agent
164
+ *
165
+ * @param agentAddress - Agent's Ethereum address
166
+ * @returns Config state (hash, CID, listed, isActive, updatedAt)
167
+ */
168
+ async getConfig(agentAddress: string): Promise<AgentConfigState> {
169
+ const contract = this.readonlyContract || this.contract;
170
+ const profile = await contract.getAgent(agentAddress);
171
+
172
+ return {
173
+ configHash: profile.configHash,
174
+ configCID: profile.configCID,
175
+ listed: profile.listed,
176
+ isActive: profile.isActive,
177
+ updatedAt: Number(profile.updatedAt),
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Check if an agent is listed on the launchpad
183
+ *
184
+ * @param agentAddress - Agent's Ethereum address
185
+ * @returns true if agent is listed
186
+ */
187
+ async isListed(agentAddress: string): Promise<boolean> {
188
+ const config = await this.getConfig(agentAddress);
189
+ return config.listed;
190
+ }
191
+
192
+ /**
193
+ * Get the on-chain config hash for an agent
194
+ *
195
+ * @param agentAddress - Agent's Ethereum address
196
+ * @returns Config hash (bytes32) or zero hash if not published
197
+ */
198
+ async getConfigHash(agentAddress: string): Promise<string> {
199
+ const config = await this.getConfig(agentAddress);
200
+ return config.configHash;
201
+ }
202
+ }
@@ -646,7 +646,9 @@ export class MockRuntime implements IACTPRuntime {
646
646
  if (newState === 'DELIVERED') {
647
647
  tx.completedAt = currentTime;
648
648
  // SECURITY FIX (PROOF-PARAM): Store delivery proof if provided
649
- if (proof) {
649
+ // Only set if not already populated (Agent sets deliveryProof before transitioning,
650
+ // and passes disputeWindowProof as the proof param — don't overwrite the real proof)
651
+ if (proof && !tx.deliveryProof) {
650
652
  tx.deliveryProof = proof;
651
653
  }
652
654
  }
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { z } from 'zod';
14
+ import type { X402FeeBreakdown } from './x402';
14
15
 
15
16
  // ============================================================================
16
17
  // AdapterMetadata - Describes adapter capabilities
@@ -249,6 +250,12 @@ export interface UnifiedPayResult {
249
250
  * Use with ReputationReporter.reportSettlement() after release.
250
251
  */
251
252
  erc8004AgentId?: string;
253
+
254
+ /**
255
+ * Fee breakdown for x402 payments routed through X402Relay.
256
+ * Present only when relay is configured and payment used the relay path.
257
+ */
258
+ feeBreakdown?: X402FeeBreakdown;
252
259
  }
253
260
 
254
261
  /**
@@ -268,6 +275,13 @@ export const UnifiedPayResultSchema = z.object({
268
275
  requester: z.string().min(1),
269
276
  deadline: z.string().min(1),
270
277
  erc8004AgentId: z.string().optional(),
278
+ feeBreakdown: z.object({
279
+ grossAmount: z.string(),
280
+ providerNet: z.string(),
281
+ platformFee: z.string(),
282
+ feeBps: z.number(),
283
+ estimated: z.literal(true),
284
+ }).optional(),
271
285
  });
272
286
 
273
287
  // ============================================================================
package/src/types/x402.ts CHANGED
@@ -194,6 +194,38 @@ export class X402Error extends Error {
194
194
  }
195
195
  }
196
196
 
197
+ // ============================================================================
198
+ // Fee Types
199
+ // ============================================================================
200
+
201
+ /**
202
+ * Fee breakdown for x402 payments routed through X402Relay.
203
+ *
204
+ * Shows how the gross amount was split between provider and platform.
205
+ * Fee = max(grossAmount * feeBps / 10000, MIN_FEE).
206
+ *
207
+ * NOTE: This is a client-side **estimate** computed from the configured
208
+ * platformFeeBps. The on-chain X402Relay contract is the source of truth.
209
+ * If an admin updates the relay's fee rate, this estimate may diverge
210
+ * from the actual on-chain split until the SDK config is refreshed.
211
+ */
212
+ export interface X402FeeBreakdown {
213
+ /** Total amount from the 402 header (USDC wei, 6 decimals) */
214
+ grossAmount: string;
215
+
216
+ /** Estimated amount provider received: grossAmount - platformFee */
217
+ providerNet: string;
218
+
219
+ /** Estimated amount treasury received: max(feeBps%, $0.05) */
220
+ platformFee: string;
221
+
222
+ /** Fee rate used for estimate (basis points, e.g. 100 = 1%) */
223
+ feeBps: number;
224
+
225
+ /** True — this is a client-side estimate, not read from chain */
226
+ estimated: true;
227
+ }
228
+
197
229
  // ============================================================================
198
230
  // Type Guards
199
231
  // ============================================================================
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Keystore auto-resolution for ACTP wallets.
3
+ *
4
+ * Resolution order:
5
+ * 1. ACTP_PRIVATE_KEY env var (backward compat, highest priority)
6
+ * 2. .actp/keystore.json decrypted with ACTP_KEY_PASSWORD
7
+ * 3. undefined (caller decides what to do)
8
+ */
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import { Wallet } from 'ethers';
12
+
13
+ // Cache keyed by resolved keystorePath to support multiple stateDirectories
14
+ const _cache = new Map<string, { key: string; address: string }>();
15
+
16
+ // Separate cache for env-var-resolved key (no path dependency)
17
+ let _envCache: { key: string; address: string } | null = null;
18
+
19
+ /**
20
+ * Validate that stateDirectory doesn't escape expected boundaries.
21
+ * Guards against path traversal when stateDirectory comes from user input.
22
+ */
23
+ function validateStateDirectory(stateDirectory: string): void {
24
+ if (stateDirectory.includes('\0')) {
25
+ throw new Error('Invalid stateDirectory: null byte detected');
26
+ }
27
+ // Reject raw '..' in the input (before normalization resolves it)
28
+ // Catches both relative traversal (../../etc) and embedded traversal (/tmp/../../etc)
29
+ if (stateDirectory.includes('..')) {
30
+ throw new Error('Invalid stateDirectory: path traversal detected (..)');
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Validate and normalize a raw private key string.
36
+ * Trims whitespace and verifies 0x-prefixed 64-char hex format.
37
+ */
38
+ function validateRawKey(raw: string, source: string): string {
39
+ const trimmed = raw.trim();
40
+ if (!/^0x[0-9a-fA-F]{64}$/.test(trimmed)) {
41
+ throw new Error(
42
+ `Invalid private key from ${source}: expected 0x-prefixed 64-char hex string`
43
+ );
44
+ }
45
+ return trimmed;
46
+ }
47
+
48
+ /**
49
+ * Auto-resolve private key: env var → keystore → undefined.
50
+ * Never logs or prints the key itself.
51
+ */
52
+ export async function resolvePrivateKey(
53
+ stateDirectory?: string
54
+ ): Promise<string | undefined> {
55
+ // 1. Env var (highest priority, backward compat)
56
+ if (process.env.ACTP_PRIVATE_KEY) {
57
+ if (_envCache) return _envCache.key;
58
+
59
+ const key = validateRawKey(process.env.ACTP_PRIVATE_KEY, 'ACTP_PRIVATE_KEY env var');
60
+ const address = new Wallet(key).address;
61
+ _envCache = { key, address };
62
+ return key;
63
+ }
64
+
65
+ // 2. Resolve keystore path
66
+ if (stateDirectory) {
67
+ validateStateDirectory(stateDirectory);
68
+ }
69
+ const actpDir = stateDirectory
70
+ ? path.join(stateDirectory, '.actp')
71
+ : path.join(process.cwd(), '.actp');
72
+ const keystorePath = path.resolve(actpDir, 'keystore.json');
73
+
74
+ // 3. Cache hit (keyed by resolved path)
75
+ const cached = _cache.get(keystorePath);
76
+ if (cached) return cached.key;
77
+
78
+ // 4. Keystore file
79
+ if (!fs.existsSync(keystorePath)) return undefined;
80
+
81
+ const password = process.env.ACTP_KEY_PASSWORD;
82
+ if (!password) {
83
+ throw new Error(
84
+ 'Keystore found at ' + keystorePath + ' but ACTP_KEY_PASSWORD is not set.\n' +
85
+ 'Set it: export ACTP_KEY_PASSWORD="your-password"'
86
+ );
87
+ }
88
+
89
+ const keystore = fs.readFileSync(keystorePath, 'utf-8');
90
+ const wallet = await Wallet.fromEncryptedJson(keystore, password);
91
+
92
+ _cache.set(keystorePath, { key: wallet.privateKey, address: wallet.address });
93
+ return wallet.privateKey;
94
+ }
95
+
96
+ /**
97
+ * Get cached address from last resolvePrivateKey() call.
98
+ * Works for both env-var and keystore resolution paths.
99
+ */
100
+ export function getCachedAddress(stateDirectory?: string): string | undefined {
101
+ // Env var path
102
+ if (_envCache) return _envCache.address;
103
+
104
+ // Keystore path — look up by resolved path
105
+ const actpDir = stateDirectory
106
+ ? path.join(stateDirectory, '.actp')
107
+ : path.join(process.cwd(), '.actp');
108
+ const keystorePath = path.resolve(actpDir, 'keystore.json');
109
+ return _cache.get(keystorePath)?.address;
110
+ }
111
+
112
+ /**
113
+ * Clear all cached keys and addresses (for testing).
114
+ * @internal
115
+ */
116
+ export function _clearCache(): void {
117
+ _cache.clear();
118
+ _envCache = null;
119
+ }