From 761b50f9fa11369a38507dab21adf65fab58fc30 Mon Sep 17 00:00:00 2001 From: aelf-hzz780 Date: Tue, 3 Mar 2026 19:25:13 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20shared=20signer?= =?UTF-8?q?=20resolver=20for=20node=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 +- README.zh-CN.md | 13 +- SKILL.md | 4 + index.ts | 16 +++ lib/signer-context.ts | 184 +++++++++++++++++++++++++ lib/types.ts | 17 +++ lib/wallet-context.ts | 198 +++++++++++++++++++++++++++ src/core/contract.ts | 12 +- src/core/query.ts | 12 +- src/mcp/server.ts | 18 +++ tests/unit/contract-core.test.ts | 2 +- tests/unit/mcp-server-schema.test.ts | 4 +- tests/unit/query-core.test.ts | 2 +- tests/unit/signer-context.test.ts | 75 ++++++++++ 14 files changed, 552 insertions(+), 18 deletions(-) create mode 100644 lib/signer-context.ts create mode 100644 lib/wallet-context.ts create mode 100644 tests/unit/signer-context.test.ts diff --git a/README.md b/README.md index 19fdfe3..ec9c203 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,9 @@ Reference file: [`mcp-config.example.json`](./mcp-config.example.json) "command": "bun", "args": ["run", "/ABSOLUTE/PATH/TO/src/mcp/server.ts"], "env": { - "AELF_PRIVATE_KEY": "your_private_key_here" + "AELF_PRIVATE_KEY": "optional_env_fallback_private_key", + "PORTKEY_WALLET_PASSWORD": "optional_wallet_password", + "PORTKEY_CA_KEYSTORE_PASSWORD": "optional_keystore_password" } } } @@ -108,8 +110,12 @@ Copy and edit: cp .env.example .env ``` -- `AELF_PRIVATE_KEY`: required for write operations -- `AELF_PRIVATE_KEY` is read from environment only in MCP mode (no private key tool input) +- `AELF_PRIVATE_KEY`: optional env fallback for write operations +- Write tools (`aelf_send_contract_transaction`, `aelf_estimate_transaction_fee`) resolve signer as `explicit -> context -> env` +- `PORTKEY_WALLET_PASSWORD`: optional password cache for EOA wallet context +- `PORTKEY_CA_KEYSTORE_PASSWORD`: optional password cache for CA keystore context +- `PORTKEY_SKILL_WALLET_CONTEXT_PATH`: optional override for active context path (`~/.portkey/skill-wallet/context.v1.json`) +- `signerMode=daemon` is reserved and currently returns `SIGNER_DAEMON_NOT_IMPLEMENTED` - `AELF_NODE_AELF_RPC_URL`: optional override for AELF node - `AELF_NODE_TDVV_RPC_URL`: optional override for tDVV node - `AELF_NODE_REGISTRY_PATH`: optional custom registry path @@ -153,6 +159,7 @@ bun run test:coverage:ci ## Security - Never put `AELF_PRIVATE_KEY` in prompts or channel outputs. +- Active wallet context must not contain plaintext private keys. - Use environment variables for all secrets. ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 09a069a..9474e04 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -93,7 +93,9 @@ aelf-node-setup claude "command": "bun", "args": ["run", "/ABSOLUTE/PATH/TO/src/mcp/server.ts"], "env": { - "AELF_PRIVATE_KEY": "your_private_key_here" + "AELF_PRIVATE_KEY": "可选_env_回退私钥", + "PORTKEY_WALLET_PASSWORD": "可选钱包密码", + "PORTKEY_CA_KEYSTORE_PASSWORD": "可选keystore密码" } } } @@ -108,8 +110,12 @@ aelf-node-setup claude cp .env.example .env ``` -- `AELF_PRIVATE_KEY`:写操作必填 -- MCP 模式仅从环境变量读取 `AELF_PRIVATE_KEY`(不接受 tool 入参传私钥) +- `AELF_PRIVATE_KEY`:写操作的 env 回退私钥(可选) +- 写操作工具(`aelf_send_contract_transaction`、`aelf_estimate_transaction_fee`)按 `explicit -> context -> env` 解析 signer +- `PORTKEY_WALLET_PASSWORD`:EOA wallet context 的密码缓存(可选) +- `PORTKEY_CA_KEYSTORE_PASSWORD`:CA keystore context 的密码缓存(可选) +- `PORTKEY_SKILL_WALLET_CONTEXT_PATH`:active context 路径覆盖(默认 `~/.portkey/skill-wallet/context.v1.json`) +- `signerMode=daemon` 仅预埋接口,本轮返回 `SIGNER_DAEMON_NOT_IMPLEMENTED` - `AELF_NODE_AELF_RPC_URL`:可选,覆盖 AELF 节点 - `AELF_NODE_TDVV_RPC_URL`:可选,覆盖 tDVV 节点 - `AELF_NODE_REGISTRY_PATH`:可选,自定义节点注册表路径 @@ -153,6 +159,7 @@ bun run test:coverage:ci ## 安全 - 不要在对话输出中暴露 `AELF_PRIVATE_KEY`。 +- Active wallet context 不存明文私钥。 - 所有密钥均通过环境变量管理。 ## License diff --git a/SKILL.md b/SKILL.md index b46ec32..12d9602 100644 --- a/SKILL.md +++ b/SKILL.md @@ -12,12 +12,14 @@ description: "AElf node querying and contract execution skill for agents." - Chain reads: status, block, transaction result, metadata - Contract operations: view call and transaction sending - Node registry import/list with REST-first and SDK fallback strategy +- Shared signer resolution for write operations: `explicit -> context -> env` - Supports SDK, CLI, MCP, and OpenClaw integration from one codebase. ## Safe usage rules - Never print private keys, mnemonics, or tokens in channel outputs. - For write operations, require explicit user confirmation and validate parameters before sending transactions. - Prefer `simulate` or read-only queries first when available. +- Active wallet context contains identity metadata only; never persist plaintext private keys. ## Command recipes - Start MCP server: `bun run mcp` @@ -25,8 +27,10 @@ description: "AElf node querying and contract execution skill for agents." - Generate OpenClaw config: `bun run build:openclaw` - Verify OpenClaw config: `bun run build:openclaw:check` - Run CI coverage gate: `bun run test:coverage:ci` +- For write calls, pass optional `signerContext` with `signerMode=auto`. ## Limits / Non-goals - This skill focuses on domain operations and adapters; it is not a full wallet custody system. - Do not hardcode environment secrets in source code or docs. - Avoid bypassing validation for external service calls. +- `signerMode=daemon` is reserved and returns `SIGNER_DAEMON_NOT_IMPLEMENTED` in this release. diff --git a/index.ts b/index.ts index 8ac9503..44092b8 100644 --- a/index.ts +++ b/index.ts @@ -14,6 +14,13 @@ export { importNode, listNodes } from './src/core/node-registry.js'; export { resolveNode, listAvailableNodes } from './lib/node-router.js'; export { clearSdkCaches, clearSdkCacheForRpc } from './lib/sdk-client.js'; +export { resolvePrivateKeyContext, SignerContextError } from './lib/signer-context.js'; +export { + readWalletContext, + writeWalletContext, + getActiveWalletProfile, + setActiveWalletProfile, +} from './lib/wallet-context.js'; export type { SkillResponse, SkillError, @@ -27,4 +34,13 @@ export type { EstimateTransactionFeeInput, ImportNodeInput, NodeProfile, + SignerMode, + SignerProvider, + SignerContextInput, } from './lib/types.js'; +export type { + WalletType, + WalletSource, + ActiveWalletProfile, + WalletContextFile, +} from './lib/wallet-context.js'; diff --git a/lib/signer-context.ts b/lib/signer-context.ts new file mode 100644 index 0000000..91756db --- /dev/null +++ b/lib/signer-context.ts @@ -0,0 +1,184 @@ +import { existsSync, readFileSync } from 'node:fs'; +import AElf from 'aelf-sdk'; +import { unlockKeystore } from 'aelf-sdk/src/util/keyStore.js'; +import { + getActiveWalletProfile, + type SignerContextInput, + type SignerProvider, +} from './wallet-context.js'; + +export class SignerContextError extends Error { + code: string; + details?: unknown; + + constructor(code: string, message: string, details?: unknown) { + super(message); + this.code = code; + this.details = details; + } +} + +export type ResolvedPrivateKeyContext = { + privateKey: string; + provider: SignerProvider; + warnings: string[]; + identity: { + walletType: 'EOA' | 'CA'; + address?: string; + caAddress?: string; + caHash?: string; + }; +}; + +function resolveExplicit(input: SignerContextInput): ResolvedPrivateKeyContext | null { + if (!input.privateKey) return null; + return { + privateKey: input.privateKey, + provider: 'explicit', + warnings: [], + identity: { + walletType: input.walletType || 'EOA', + address: input.address, + caAddress: input.caAddress, + caHash: input.caHash, + }, + }; +} + +function resolveContext(input: SignerContextInput): ResolvedPrivateKeyContext { + const profile = getActiveWalletProfile(); + if (!profile) { + throw new SignerContextError('SIGNER_CONTEXT_NOT_FOUND', 'active wallet context not found'); + } + + if (profile.walletType === 'EOA') { + const password = input.password || process.env.PORTKEY_WALLET_PASSWORD; + if (!password) { + throw new SignerContextError( + 'SIGNER_PASSWORD_REQUIRED', + 'password required for active EOA wallet (set PORTKEY_WALLET_PASSWORD or pass signer.password)', + ); + } + if (!profile.walletFile || !existsSync(profile.walletFile)) { + throw new SignerContextError( + 'SIGNER_CONTEXT_INVALID', + `active EOA wallet file not found: ${profile.walletFile || ''}`, + ); + } + const raw = JSON.parse(readFileSync(profile.walletFile, 'utf8')) as Record; + const encrypted = + typeof raw.AESEncryptPrivateKey === 'string' ? raw.AESEncryptPrivateKey : ''; + if (!encrypted) { + throw new SignerContextError( + 'SIGNER_CONTEXT_INVALID', + 'active EOA wallet file missing AESEncryptPrivateKey', + ); + } + const privateKey = AElf.wallet.AESDecrypt(encrypted, password); + if (!privateKey) { + throw new SignerContextError( + 'SIGNER_PASSWORD_REQUIRED', + 'failed to decrypt active EOA wallet: wrong password or corrupted file', + ); + } + return { + privateKey, + provider: 'context', + warnings: [], + identity: { + walletType: 'EOA', + address: profile.address || (typeof raw.address === 'string' ? raw.address : undefined), + }, + }; + } + + const password = input.password || process.env.PORTKEY_CA_KEYSTORE_PASSWORD; + if (!password) { + throw new SignerContextError( + 'SIGNER_PASSWORD_REQUIRED', + 'password required for active CA keystore (set PORTKEY_CA_KEYSTORE_PASSWORD or pass signer.password)', + ); + } + if (!profile.keystoreFile || !existsSync(profile.keystoreFile)) { + throw new SignerContextError( + 'SIGNER_CONTEXT_INVALID', + `active CA keystore not found: ${profile.keystoreFile || ''}`, + ); + } + const raw = JSON.parse(readFileSync(profile.keystoreFile, 'utf8')) as Record; + const decrypted = unlockKeystore(raw.keystore, password); + if (!decrypted?.privateKey) { + throw new SignerContextError( + 'SIGNER_PASSWORD_REQUIRED', + 'failed to decrypt active CA keystore: wrong password or corrupted file', + ); + } + return { + privateKey: decrypted.privateKey, + provider: 'context', + warnings: [], + identity: { + walletType: 'CA', + address: profile.address, + caHash: profile.caHash || (typeof raw.caHash === 'string' ? raw.caHash : undefined), + caAddress: + profile.caAddress || (typeof raw.caAddress === 'string' ? raw.caAddress : undefined), + }, + }; +} + +export function resolvePrivateKeyContext( + input: SignerContextInput = {}, +): ResolvedPrivateKeyContext { + const mode = input.signerMode || 'auto'; + + if (mode === 'daemon') { + throw new SignerContextError( + 'SIGNER_DAEMON_NOT_IMPLEMENTED', + 'daemon signer provider is reserved for future release', + ); + } + + if (mode === 'explicit' || mode === 'auto') { + const explicit = resolveExplicit(input); + if (explicit) return explicit; + } + + if (mode === 'context' || mode === 'auto') { + try { + return resolveContext(input); + } catch (error) { + if (mode === 'context') throw error; + } + } + + if (mode === 'env' || mode === 'auto') { + const privateKey = + input.privateKey || + process.env.AELF_PRIVATE_KEY || + process.env.PORTKEY_PRIVATE_KEY; + if (privateKey) { + return { + privateKey, + provider: 'env', + warnings: [], + identity: { + walletType: + process.env.PORTKEY_CA_HASH && process.env.PORTKEY_CA_ADDRESS + ? 'CA' + : 'EOA', + caHash: process.env.PORTKEY_CA_HASH, + caAddress: process.env.PORTKEY_CA_ADDRESS, + }, + }; + } + if (mode === 'env') { + throw new SignerContextError('SIGNER_CONTEXT_NOT_FOUND', 'no private key available from env'); + } + } + + throw new SignerContextError( + 'SIGNER_CONTEXT_NOT_FOUND', + 'no signer available from explicit/context/env', + ); +} diff --git a/lib/types.ts b/lib/types.ts index 12d8bdd..e9c2a4f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,4 +1,17 @@ export type ChainId = 'AELF' | 'tDVV' | string; +export type SignerMode = 'auto' | 'explicit' | 'context' | 'env' | 'daemon'; +export type SignerProvider = 'explicit' | 'context' | 'env' | 'daemon'; + +export type SignerContextInput = { + signerMode?: SignerMode; + walletType?: 'EOA' | 'CA'; + address?: string; + password?: string; + privateKey?: string; + caHash?: string; + caAddress?: string; + network?: 'mainnet' | 'testnet'; +}; export interface SkillError { code: string; @@ -78,6 +91,8 @@ export interface SendContractTransactionInput extends ChainTargetInput { maxRetries?: number; retryIntervalMs?: number; privateKey?: string; + signer?: SignerContextInput; + signerContext?: SignerContextInput; } export interface EstimateTransactionFeeInput extends ChainTargetInput { @@ -86,6 +101,8 @@ export interface EstimateTransactionFeeInput extends ChainTargetInput { methodName?: string; params?: Record; privateKey?: string; + signer?: SignerContextInput; + signerContext?: SignerContextInput; } export interface ImportNodeInput { diff --git a/lib/wallet-context.ts b/lib/wallet-context.ts new file mode 100644 index 0000000..733dab6 --- /dev/null +++ b/lib/wallet-context.ts @@ -0,0 +1,198 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; + +export type WalletType = 'EOA' | 'CA'; +export type WalletSource = 'eoa-local' | 'ca-keystore' | 'env'; +export type SignerMode = 'auto' | 'explicit' | 'context' | 'env' | 'daemon'; +export type SignerProvider = 'explicit' | 'context' | 'env' | 'daemon'; + +export type SignerContextInput = { + signerMode?: SignerMode; + walletType?: WalletType; + address?: string; + password?: string; + privateKey?: string; + caHash?: string; + caAddress?: string; + network?: 'mainnet' | 'testnet'; +}; + +export type ActiveWalletProfile = { + walletType: WalletType; + source: WalletSource; + network?: string; + address?: string; + caAddress?: string; + caHash?: string; + walletFile?: string; + keystoreFile?: string; + updatedAt: string; +}; + +export type WalletContextWriter = { + skill: string; + version: string; +}; + +export type WalletContextFile = { + version: 1; + activeProfileId: string; + profiles: Record; + lastWriter: WalletContextWriter; +}; + +const CONTEXT_VERSION = 1 as const; +const DEFAULT_PROFILE_ID = 'default'; + +function getContextPath(): string { + const override = process.env.PORTKEY_SKILL_WALLET_CONTEXT_PATH; + if (override) return resolve(override); + return join(homedir(), '.portkey', 'skill-wallet', 'context.v1.json'); +} + +function ensureDir(pathname: string): void { + const dir = dirname(pathname); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + try { + chmodSync(dir, 0o700); + } catch { + // Ignore permission errors for externally managed parent dirs (e.g. /tmp in tests). + } +} + +function parseProfile(value: unknown): ActiveWalletProfile | null { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + if (record.walletType !== 'EOA' && record.walletType !== 'CA') return null; + if ( + record.source !== 'eoa-local' && + record.source !== 'ca-keystore' && + record.source !== 'env' + ) { + return null; + } + const updatedAt = typeof record.updatedAt === 'string' ? record.updatedAt : ''; + if (!updatedAt) return null; + + return { + walletType: record.walletType, + source: record.source, + network: typeof record.network === 'string' ? record.network : undefined, + address: typeof record.address === 'string' ? record.address : undefined, + caAddress: typeof record.caAddress === 'string' ? record.caAddress : undefined, + caHash: typeof record.caHash === 'string' ? record.caHash : undefined, + walletFile: typeof record.walletFile === 'string' ? record.walletFile : undefined, + keystoreFile: typeof record.keystoreFile === 'string' ? record.keystoreFile : undefined, + updatedAt, + }; +} + +function parseContext(raw: unknown): WalletContextFile | null { + if (!raw || typeof raw !== 'object') return null; + const record = raw as Record; + if (record.version !== CONTEXT_VERSION) return null; + + const activeProfileId = + typeof record.activeProfileId === 'string' && record.activeProfileId + ? record.activeProfileId + : DEFAULT_PROFILE_ID; + const profilesInput = + record.profiles && typeof record.profiles === 'object' + ? (record.profiles as Record) + : {}; + const profiles: Record = {}; + for (const [id, candidate] of Object.entries(profilesInput)) { + const parsed = parseProfile(candidate); + if (parsed) profiles[id] = parsed; + } + + const lastWriterInput = + record.lastWriter && typeof record.lastWriter === 'object' + ? (record.lastWriter as Record) + : {}; + const lastWriter: WalletContextWriter = { + skill: + typeof lastWriterInput.skill === 'string' + ? lastWriterInput.skill + : 'unknown', + version: + typeof lastWriterInput.version === 'string' + ? lastWriterInput.version + : '0.0.0', + }; + + return { + version: CONTEXT_VERSION, + activeProfileId, + profiles, + lastWriter, + }; +} + +export function readWalletContext(): WalletContextFile | null { + const filePath = getContextPath(); + if (!existsSync(filePath)) return null; + + try { + const raw = JSON.parse(readFileSync(filePath, 'utf8')) as unknown; + return parseContext(raw); + } catch { + return null; + } +} + +export function writeWalletContext(context: WalletContextFile): void { + const filePath = getContextPath(); + ensureDir(filePath); + + const payload = JSON.stringify(context, null, 2) + '\n'; + const tempPath = `${filePath}.tmp`; + writeFileSync(tempPath, payload, { encoding: 'utf8', mode: 0o600 }); + chmodSync(tempPath, 0o600); + renameSync(tempPath, filePath); + chmodSync(filePath, 0o600); +} + +export function setActiveWalletProfile( + profile: Omit & { profileId?: string }, + writer: WalletContextWriter, +): WalletContextFile { + const profileId = profile.profileId || DEFAULT_PROFILE_ID; + const current = + readWalletContext() || + ({ + version: CONTEXT_VERSION, + activeProfileId: profileId, + profiles: {}, + lastWriter: writer, + } as WalletContextFile); + + current.activeProfileId = profileId; + current.profiles[profileId] = { + ...profile, + updatedAt: new Date().toISOString(), + }; + current.lastWriter = writer; + + writeWalletContext(current); + return current; +} + +export function getActiveWalletProfile( + profileId?: string, +): ActiveWalletProfile | null { + const context = readWalletContext(); + if (!context) return null; + const id = profileId || context.activeProfileId || DEFAULT_PROFILE_ID; + return context.profiles[id] || null; +} diff --git a/src/core/contract.ts b/src/core/contract.ts index a7bfddf..07a9104 100644 --- a/src/core/contract.ts +++ b/src/core/contract.ts @@ -4,6 +4,7 @@ import { callContractView as callContractViewBySdk, sendContractTransaction as s import { validateChainTargetInput, validateContractAddress, validateMethodName } from '../../lib/validators.js'; import { executeWithResponse } from './common.js'; import type { CallContractViewInput, SendContractTransactionInput, SkillResponse } from '../../lib/types.js'; +import { resolvePrivateKeyContext } from '../../lib/signer-context.js'; export async function callContractView(input: CallContractViewInput): Promise> { return executeWithResponse(async () => { @@ -23,10 +24,13 @@ export async function sendContractTransaction(input: SendContractTransactionInpu validateMethodName(input.methodName); const { node } = await resolveNode(input); - const privateKey = getEoaPrivateKey(input.privateKey); - if (!privateKey) { - throw new Error('AELF_PRIVATE_KEY is required for write operations'); - } + const resolved = resolvePrivateKeyContext({ + signerMode: 'auto', + ...(input.signer || {}), + ...(input.signerContext || {}), + privateKey: getEoaPrivateKey(input.privateKey), + }); + const privateKey = resolved.privateKey; return sendContractTransactionBySdk( node.rpcUrl, diff --git a/src/core/query.ts b/src/core/query.ts index 3b70932..6a75422 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -24,6 +24,7 @@ import type { GetTransactionResultInput, SkillResponse, } from '../../lib/types.js'; +import { resolvePrivateKeyContext } from '../../lib/signer-context.js'; const restClientCache = new LruCache(getRestClientCacheMax()); @@ -134,10 +135,13 @@ async function resolveRawTransaction(input: EstimateTransactionFeeInput, rpcUrl: validateContractAddress(input.contractAddress); validateMethodName(input.methodName); - const privateKey = getEoaPrivateKey(input.privateKey); - if (!privateKey) { - throw new Error('AELF_PRIVATE_KEY is required when rawTransaction is not provided'); - } + const resolved = resolvePrivateKeyContext({ + signerMode: 'auto', + ...(input.signer || {}), + ...(input.signerContext || {}), + privateKey: getEoaPrivateKey(input.privateKey), + }); + const privateKey = resolved.privateKey; return buildSignedTransaction( rpcUrl, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 61172c9..112cd04 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -39,6 +39,20 @@ const chainTargetSchema = { rpcUrl: z.string().optional().describe('Direct rpc url override, only http/https is accepted'), }; +const signerContextSchema = z + .object({ + signerMode: z.enum(['auto', 'explicit', 'context', 'env', 'daemon']).optional(), + walletType: z.enum(['EOA', 'CA']).optional(), + address: z.string().optional(), + password: z.string().optional(), + privateKey: z.string().optional(), + caHash: z.string().optional(), + caAddress: z.string().optional(), + network: z.enum(['mainnet', 'testnet']).optional(), + }) + .optional() + .describe('Optional signer context. auto tries explicit → active context → env.'); + server.registerTool( 'aelf_get_chain_status', { @@ -132,6 +146,8 @@ server.registerTool( waitForMined: z.boolean().optional().default(true), maxRetries: z.number().int().optional().default(20), retryIntervalMs: z.number().int().optional().default(1500), + signer: signerContextSchema, + signerContext: signerContextSchema, }, }, async input => asMcpResult(await sendContractTransaction(input)), @@ -147,6 +163,8 @@ server.registerTool( contractAddress: z.string().optional(), methodName: z.string().optional(), params: z.record(z.unknown()).optional(), + signer: signerContextSchema, + signerContext: signerContextSchema, }, }, async input => asMcpResult(await estimateTransactionFee(input)), diff --git a/tests/unit/contract-core.test.ts b/tests/unit/contract-core.test.ts index 8a942b0..385f284 100644 --- a/tests/unit/contract-core.test.ts +++ b/tests/unit/contract-core.test.ts @@ -159,7 +159,7 @@ describe('core/contract', () => { }); expect(result.ok).toBe(false); - expect(result.error?.message.includes('AELF_PRIVATE_KEY is required for write operations')).toBe(true); + expect(result.error?.message.includes('no signer available from explicit/context/env')).toBe(true); expect(state.sendContractTransactionCalls.length).toBe(0); }); diff --git a/tests/unit/mcp-server-schema.test.ts b/tests/unit/mcp-server-schema.test.ts index 2d837b0..939b473 100644 --- a/tests/unit/mcp-server-schema.test.ts +++ b/tests/unit/mcp-server-schema.test.ts @@ -3,11 +3,11 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; describe('mcp server schema', () => { - it('does not expose privateKey in mcp tool inputs and uses package version', () => { + it('uses package version and exposes signer context schema', () => { const serverPath = resolve(process.cwd(), 'src/mcp/server.ts'); const source = readFileSync(serverPath, 'utf8'); - expect(source.includes("privateKey: z.string")).toBe(false); + expect(source.includes('const signerContextSchema')).toBe(true); expect(source.includes('version: packageJson.version')).toBe(true); }); }); diff --git a/tests/unit/query-core.test.ts b/tests/unit/query-core.test.ts index a972bf0..5f050de 100644 --- a/tests/unit/query-core.test.ts +++ b/tests/unit/query-core.test.ts @@ -131,6 +131,6 @@ describe('query core flows', () => { expect(result.ok).toBe(false); expect(result.error?.code).toBe('ESTIMATE_TRANSACTION_FEE_FAILED'); - expect(result.error?.message.includes('AELF_PRIVATE_KEY is required when rawTransaction is not provided')).toBe(true); + expect(result.error?.message.includes('no signer available from explicit/context/env')).toBe(true); }); }); diff --git a/tests/unit/signer-context.test.ts b/tests/unit/signer-context.test.ts new file mode 100644 index 0000000..f56be34 --- /dev/null +++ b/tests/unit/signer-context.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import AElf from 'aelf-sdk'; +import { resolvePrivateKeyContext } from '../../lib/signer-context.js'; +import { setActiveWalletProfile } from '../../lib/wallet-context.js'; + +describe('lib/signer-context', () => { + let tempDir: string | null = null; + + afterEach(() => { + delete process.env.PORTKEY_SKILL_WALLET_CONTEXT_PATH; + delete process.env.PORTKEY_WALLET_PASSWORD; + delete process.env.AELF_PRIVATE_KEY; + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + test('resolves explicit private key', () => { + const wallet = AElf.wallet.createNewWallet(); + const resolved = resolvePrivateKeyContext({ + signerMode: 'explicit', + privateKey: wallet.privateKey, + }); + expect(resolved.provider).toBe('explicit'); + expect(resolved.privateKey).toBe(wallet.privateKey); + }); + + test('resolves active EOA context with password env', () => { + tempDir = mkdtempSync(join(tmpdir(), 'aelf-node-signer-context-')); + const contextPath = join(tempDir, 'context.v1.json'); + const walletFile = join(tempDir, 'wallet.json'); + process.env.PORTKEY_SKILL_WALLET_CONTEXT_PATH = contextPath; + + const password = 'secret'; + const wallet = AElf.wallet.createNewWallet(); + writeFileSync( + walletFile, + JSON.stringify( + { + address: wallet.address, + AESEncryptPrivateKey: AElf.wallet.AESEncrypt(wallet.privateKey, password), + }, + null, + 2, + ), + ); + + setActiveWalletProfile( + { + walletType: 'EOA', + source: 'eoa-local', + address: wallet.address, + walletFile, + }, + { skill: 'test', version: '0.0.0' }, + ); + + process.env.PORTKEY_WALLET_PASSWORD = password; + const resolved = resolvePrivateKeyContext({ signerMode: 'context' }); + expect(resolved.provider).toBe('context'); + expect(resolved.privateKey).toBe(wallet.privateKey); + }); + + test('auto mode falls back to env', () => { + const wallet = AElf.wallet.createNewWallet(); + process.env.AELF_PRIVATE_KEY = wallet.privateKey; + const resolved = resolvePrivateKeyContext({ signerMode: 'auto' }); + expect(resolved.provider).toBe('env'); + expect(resolved.privateKey).toBe(wallet.privateKey); + }); +}); From 58305d0b21c2174453413b8d72d154cace5f4a61 Mon Sep 17 00:00:00 2001 From: aelf-hzz780 Date: Wed, 4 Mar 2026 19:26:32 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=F0=9F=90=9B=20preserve=20signer=20e?= =?UTF-8?q?rror=20codes=20and=20emit=20structured=20MCP=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- README.zh-CN.md | 3 +- bun.lock | 3 +- lib/errors.ts | 11 ++- lib/signer-context.ts | 6 ++ lib/wallet-context.ts | 120 ++++++++++++++++++++++---- package.json | 1 + schemas/wallet-context.v1.schema.json | 101 ++++++++++++++++++++++ scripts/check-deps-baseline.ts | 28 +++++- src/mcp/server.ts | 28 ++++++ tests/unit/contract-core.test.ts | 4 +- tests/unit/errors-normalize.test.ts | 15 ++++ tests/unit/query-core.test.ts | 5 +- tests/unit/signer-context.test.ts | 115 +++++++++++++++++++++++- 14 files changed, 412 insertions(+), 31 deletions(-) create mode 100644 schemas/wallet-context.v1.schema.json create mode 100644 tests/unit/errors-normalize.test.ts diff --git a/README.md b/README.md index ec9c203..f1898c0 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,8 @@ Copy and edit: cp .env.example .env ``` -- `AELF_PRIVATE_KEY`: optional env fallback for write operations +- `AELF_PRIVATE_KEY`: optional env fallback for write operations (highest env priority) +- `PORTKEY_PRIVATE_KEY`: optional secondary env fallback for shared-skill compatibility - Write tools (`aelf_send_contract_transaction`, `aelf_estimate_transaction_fee`) resolve signer as `explicit -> context -> env` - `PORTKEY_WALLET_PASSWORD`: optional password cache for EOA wallet context - `PORTKEY_CA_KEYSTORE_PASSWORD`: optional password cache for CA keystore context diff --git a/README.zh-CN.md b/README.zh-CN.md index 9474e04..c637dfc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -110,7 +110,8 @@ aelf-node-setup claude cp .env.example .env ``` -- `AELF_PRIVATE_KEY`:写操作的 env 回退私钥(可选) +- `AELF_PRIVATE_KEY`:写操作的 env 回退私钥(可选,env 优先级最高) +- `PORTKEY_PRIVATE_KEY`:共享 skill 兼容的次级 env 回退私钥(可选) - 写操作工具(`aelf_send_contract_transaction`、`aelf_estimate_transaction_fee`)按 `explicit -> context -> env` 解析 signer - `PORTKEY_WALLET_PASSWORD`:EOA wallet context 的密码缓存(可选) - `PORTKEY_CA_KEYSTORE_PASSWORD`:CA keystore context 的密码缓存(可选) diff --git a/bun.lock b/bun.lock index e6f8a89..7d55c01 100644 --- a/bun.lock +++ b/bun.lock @@ -6,12 +6,13 @@ "name": "@blockchain-forever/aelf-node-skill", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", - "aelf-sdk": "3.5.1-beta.0", + "aelf-sdk": "^3.5.1-beta.0", "commander": "^12.1.0", "zod": "^3.24.0", }, "devDependencies": { "@types/bun": "latest", + "ajv": "^8.17.1", "typescript": "^5.7.0", }, }, diff --git a/lib/errors.ts b/lib/errors.ts index 41e05be..c3bc802 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -44,10 +44,17 @@ export function normalizeError(input: unknown, fallbackCode = 'UNKNOWN_ERROR'): } if (input instanceof Error) { + const maybeCode = + typeof (input as { code?: unknown }).code === 'string' + ? String((input as { code?: unknown }).code) + : ''; + const maybeDetails = (input as { details?: unknown }).details; + const maybeRaw = (input as { raw?: unknown }).raw; return { - code: fallbackCode, + code: maybeCode || fallbackCode, message: input.message, - raw: { name: input.name, stack: input.stack }, + details: maybeDetails !== undefined ? stringifyRaw(maybeDetails) : '', + raw: maybeRaw !== undefined ? maybeRaw : { name: input.name, stack: input.stack }, }; } diff --git a/lib/signer-context.ts b/lib/signer-context.ts index 91756db..4a36eae 100644 --- a/lib/signer-context.ts +++ b/lib/signer-context.ts @@ -131,6 +131,7 @@ export function resolvePrivateKeyContext( input: SignerContextInput = {}, ): ResolvedPrivateKeyContext { const mode = input.signerMode || 'auto'; + let contextError: unknown = null; if (mode === 'daemon') { throw new SignerContextError( @@ -148,6 +149,7 @@ export function resolvePrivateKeyContext( try { return resolveContext(input); } catch (error) { + contextError = error; if (mode === 'context') throw error; } } @@ -177,6 +179,10 @@ export function resolvePrivateKeyContext( } } + if (contextError) { + throw contextError; + } + throw new SignerContextError( 'SIGNER_CONTEXT_NOT_FOUND', 'no signer available from explicit/context/env', diff --git a/lib/wallet-context.ts b/lib/wallet-context.ts index 733dab6..e86ce9b 100644 --- a/lib/wallet-context.ts +++ b/lib/wallet-context.ts @@ -1,9 +1,13 @@ import { + closeSync, chmodSync, existsSync, mkdirSync, + openSync, readFileSync, renameSync, + statSync, + unlinkSync, writeFileSync, } from 'node:fs'; import { homedir } from 'node:os'; @@ -51,6 +55,9 @@ export type WalletContextFile = { const CONTEXT_VERSION = 1 as const; const DEFAULT_PROFILE_ID = 'default'; +const LOCK_RETRY_INTERVAL_MS = 50; +const LOCK_MAX_RETRIES = 20; +const LOCK_STALE_MS = 30_000; function getContextPath(): string { const override = process.env.PORTKEY_SKILL_WALLET_CONTEXT_PATH; @@ -58,6 +65,13 @@ function getContextPath(): string { return join(homedir(), '.portkey', 'skill-wallet', 'context.v1.json'); } +function sleepMs(ms: number): void { + const end = Date.now() + ms; + while (Date.now() < end) { + // busy wait for a very short lock retry window + } +} + function ensureDir(pathname: string): void { const dir = dirname(pathname); if (!existsSync(dir)) { @@ -141,6 +155,10 @@ function parseContext(raw: unknown): WalletContextFile | null { export function readWalletContext(): WalletContextFile | null { const filePath = getContextPath(); + return readWalletContextFromPath(filePath); +} + +function readWalletContextFromPath(filePath: string): WalletContextFile | null { if (!existsSync(filePath)) return null; try { @@ -153,8 +171,13 @@ export function readWalletContext(): WalletContextFile | null { export function writeWalletContext(context: WalletContextFile): void { const filePath = getContextPath(); - ensureDir(filePath); + withContextLock(filePath, () => { + writeWalletContextFile(filePath, context); + }); +} +function writeWalletContextFile(filePath: string, context: WalletContextFile): void { + ensureDir(filePath); const payload = JSON.stringify(context, null, 2) + '\n'; const tempPath = `${filePath}.tmp`; writeFileSync(tempPath, payload, { encoding: 'utf8', mode: 0o600 }); @@ -163,29 +186,88 @@ export function writeWalletContext(context: WalletContextFile): void { chmodSync(filePath, 0o600); } +function withContextLock(filePath: string, action: () => T): T { + ensureDir(filePath); + const lockPath = `${filePath}.lock`; + let retries = 0; + + while (retries <= LOCK_MAX_RETRIES) { + try { + if (existsSync(lockPath)) { + const ageMs = Date.now() - statSync(lockPath).mtimeMs; + if (ageMs > LOCK_STALE_MS) { + unlinkSync(lockPath); + } + } + } catch { + // best effort stale lock cleanup + } + + let fd: number | null = null; + try { + fd = openSync(lockPath, 'wx', 0o600); + const result = action(); + closeSync(fd); + fd = null; + unlinkSync(lockPath); + return result; + } catch (error) { + if (fd !== null) { + try { + closeSync(fd); + } catch { + // noop + } + } + + const code = error && typeof error === 'object' + ? String((error as { code?: unknown }).code || '') + : ''; + if (code !== 'EEXIST') { + throw error; + } + + retries += 1; + if (retries > LOCK_MAX_RETRIES) { + throw new Error( + `SIGNER_CONTEXT_LOCK_TIMEOUT: failed to acquire context lock after ${LOCK_MAX_RETRIES} retries`, + ); + } + sleepMs(LOCK_RETRY_INTERVAL_MS); + } + } + + throw new Error( + `SIGNER_CONTEXT_LOCK_TIMEOUT: failed to acquire context lock after ${LOCK_MAX_RETRIES} retries`, + ); +} + export function setActiveWalletProfile( profile: Omit & { profileId?: string }, writer: WalletContextWriter, ): WalletContextFile { - const profileId = profile.profileId || DEFAULT_PROFILE_ID; - const current = - readWalletContext() || - ({ - version: CONTEXT_VERSION, - activeProfileId: profileId, - profiles: {}, - lastWriter: writer, - } as WalletContextFile); - - current.activeProfileId = profileId; - current.profiles[profileId] = { - ...profile, - updatedAt: new Date().toISOString(), - }; - current.lastWriter = writer; + const filePath = getContextPath(); + return withContextLock(filePath, () => { + const profileId = profile.profileId || DEFAULT_PROFILE_ID; + const current = + readWalletContextFromPath(filePath) || + ({ + version: CONTEXT_VERSION, + activeProfileId: profileId, + profiles: {}, + lastWriter: writer, + } as WalletContextFile); + + current.activeProfileId = profileId; + current.profiles[profileId] = { + ...profile, + updatedAt: new Date().toISOString(), + }; + current.lastWriter = writer; - writeWalletContext(current); - return current; + writeWalletContextFile(filePath, current); + return current; + }); } export function getActiveWalletProfile( diff --git a/package.json b/package.json index 845b8f2..db64d7c 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ }, "devDependencies": { "@types/bun": "latest", + "ajv": "^8.17.1", "typescript": "^5.7.0" } } diff --git a/schemas/wallet-context.v1.schema.json b/schemas/wallet-context.v1.schema.json new file mode 100644 index 0000000..32e8d09 --- /dev/null +++ b/schemas/wallet-context.v1.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/AElfProject/aelf-skills/docs/schemas/wallet-context.v1.schema.json", + "title": "WalletContextFileV1", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "activeProfileId", + "profiles", + "lastWriter" + ], + "properties": { + "version": { + "type": "integer", + "const": 1 + }, + "activeProfileId": { + "type": "string", + "minLength": 1 + }, + "profiles": { + "type": "object", + "minProperties": 0, + "additionalProperties": { + "$ref": "#/$defs/activeProfile" + } + }, + "lastWriter": { + "$ref": "#/$defs/lastWriter" + } + }, + "$defs": { + "activeProfile": { + "type": "object", + "additionalProperties": false, + "required": [ + "walletType", + "source", + "updatedAt" + ], + "properties": { + "walletType": { + "type": "string", + "enum": [ + "EOA", + "CA" + ] + }, + "source": { + "type": "string", + "enum": [ + "eoa-local", + "ca-keystore", + "env" + ] + }, + "network": { + "type": "string" + }, + "address": { + "type": "string" + }, + "caAddress": { + "type": "string" + }, + "caHash": { + "type": "string" + }, + "walletFile": { + "type": "string" + }, + "keystoreFile": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "lastWriter": { + "type": "object", + "additionalProperties": false, + "required": [ + "skill", + "version" + ], + "properties": { + "skill": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + } + } + } + } +} diff --git a/scripts/check-deps-baseline.ts b/scripts/check-deps-baseline.ts index 812c4e9..a62b2e4 100644 --- a/scripts/check-deps-baseline.ts +++ b/scripts/check-deps-baseline.ts @@ -1,6 +1,8 @@ #!/usr/bin/env bun import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; import { resolve } from 'node:path'; +import Ajv from 'ajv'; type Baseline = { dependencies: Record; @@ -14,6 +16,7 @@ function main() { const cwd = process.cwd(); const baselinePath = resolve(cwd, 'deps-baseline.json'); const packagePath = resolve(cwd, 'package.json'); + const contextSchemaPath = resolve(cwd, 'schemas', 'wallet-context.v1.schema.json'); if (!existsSync(baselinePath)) { console.error(`[deps:check] missing deps-baseline.json at ${baselinePath}`); @@ -44,8 +47,31 @@ function main() { } } + const contextPath = + process.env.PORTKEY_SKILL_WALLET_CONTEXT_PATH || + resolve(homedir(), '.portkey', 'skill-wallet', 'context.v1.json'); + if (!existsSync(contextSchemaPath)) { + failures.push(`missing wallet-context schema: ${contextSchemaPath}`); + } else if (existsSync(contextPath)) { + try { + const schema = readJson>(contextSchemaPath); + const contextRaw = readJson>(contextPath); + const ajv = new Ajv({ allErrors: true, strict: false }); + const validate = ajv.compile(schema); + if (!validate(contextRaw)) { + const details = (validate.errors || []) + .map((err) => `${err.instancePath || '/'} ${err.message || 'invalid'}`) + .join('; '); + failures.push(`wallet-context schema validation failed (${contextPath}): ${details}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push(`wallet-context parse/validation failed: ${message}`); + } + } + if (failures.length > 0) { - console.error('[deps:check] dependency baseline mismatch:'); + console.error('[deps:check] check failed:'); for (const failure of failures) { console.error(`- ${failure}`); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 112cd04..b2123f8 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -23,6 +23,34 @@ const server = new McpServer({ }); function asMcpResult(data: unknown) { + if ( + data && + typeof data === 'object' && + 'ok' in data && + (data as { ok?: unknown }).ok === false + ) { + const record = data as Record; + const error = + record.error && typeof record.error === 'object' + ? (record.error as Record) + : {}; + const code = typeof error.code === 'string' ? error.code : 'UNKNOWN_ERROR'; + const message = typeof error.message === 'string' ? error.message : 'Unknown error'; + return { + content: [ + { + type: 'text' as const, + text: `[ERROR] ${code}: ${message}`, + }, + { + type: 'text' as const, + text: JSON.stringify(data, null, 2), + }, + ], + isError: true as const, + }; + } + return { content: [ { diff --git a/tests/unit/contract-core.test.ts b/tests/unit/contract-core.test.ts index 385f284..1ba0373 100644 --- a/tests/unit/contract-core.test.ts +++ b/tests/unit/contract-core.test.ts @@ -147,6 +147,7 @@ describe('core/contract', () => { it('fails send call when private key is missing', async () => { delete process.env.AELF_PRIVATE_KEY; + delete process.env.PORTKEY_PRIVATE_KEY; const result = await contractCore.sendContractTransaction({ rpcUrl: 'https://mock-node.test', @@ -159,7 +160,8 @@ describe('core/contract', () => { }); expect(result.ok).toBe(false); - expect(result.error?.message.includes('no signer available from explicit/context/env')).toBe(true); + expect(result.error?.code).toBe('SIGNER_CONTEXT_NOT_FOUND'); + expect(result.error?.message.includes('active wallet context not found')).toBe(true); expect(state.sendContractTransactionCalls.length).toBe(0); }); diff --git a/tests/unit/errors-normalize.test.ts b/tests/unit/errors-normalize.test.ts new file mode 100644 index 0000000..ccf4e52 --- /dev/null +++ b/tests/unit/errors-normalize.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'bun:test'; +import { normalizeError } from '../../lib/errors.js'; + +describe('lib/errors normalizeError', () => { + test('preserves explicit error code on Error-like input', () => { + const err = new Error('missing signer context') as Error & { code: string; details: string }; + err.code = 'SIGNER_CONTEXT_NOT_FOUND'; + err.details = 'active profile missing'; + + const normalized = normalizeError(err, 'SEND_CONTRACT_TRANSACTION_FAILED'); + expect(normalized.code).toBe('SIGNER_CONTEXT_NOT_FOUND'); + expect(normalized.message).toBe('missing signer context'); + expect(normalized.details).toBe('active profile missing'); + }); +}); diff --git a/tests/unit/query-core.test.ts b/tests/unit/query-core.test.ts index 5f050de..9fbfbf1 100644 --- a/tests/unit/query-core.test.ts +++ b/tests/unit/query-core.test.ts @@ -118,6 +118,7 @@ describe('query core flows', () => { it('returns error when private key is missing for signed tx build', async () => { delete process.env.AELF_PRIVATE_KEY; + delete process.env.PORTKEY_PRIVATE_KEY; const result = await estimateTransactionFee({ rpcUrl: 'https://mock-node.test', @@ -130,7 +131,7 @@ describe('query core flows', () => { }); expect(result.ok).toBe(false); - expect(result.error?.code).toBe('ESTIMATE_TRANSACTION_FEE_FAILED'); - expect(result.error?.message.includes('no signer available from explicit/context/env')).toBe(true); + expect(result.error?.code).toBe('SIGNER_CONTEXT_NOT_FOUND'); + expect(result.error?.message.includes('active wallet context not found')).toBe(true); }); }); diff --git a/tests/unit/signer-context.test.ts b/tests/unit/signer-context.test.ts index f56be34..3a5f651 100644 --- a/tests/unit/signer-context.test.ts +++ b/tests/unit/signer-context.test.ts @@ -1,18 +1,46 @@ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import AElf from 'aelf-sdk'; -import { resolvePrivateKeyContext } from '../../lib/signer-context.js'; +import { getKeystore } from 'aelf-sdk/src/util/keyStore.js'; +import { SignerContextError, resolvePrivateKeyContext } from '../../lib/signer-context.js'; import { setActiveWalletProfile } from '../../lib/wallet-context.js'; describe('lib/signer-context', () => { let tempDir: string | null = null; - afterEach(() => { + const clearSignerEnv = () => { delete process.env.PORTKEY_SKILL_WALLET_CONTEXT_PATH; delete process.env.PORTKEY_WALLET_PASSWORD; delete process.env.AELF_PRIVATE_KEY; + delete process.env.PORTKEY_PRIVATE_KEY; + delete process.env.EFOREST_PRIVATE_KEY; + delete process.env.TMRW_PRIVATE_KEY; + delete process.env.PORTKEY_CA_HASH; + delete process.env.PORTKEY_CA_ADDRESS; + delete process.env.PORTKEY_CA_KEYSTORE_PASSWORD; + }; + + const expectSignerErrorCode = ( + action: () => unknown, + expectedCode: string, + ) => { + try { + action(); + throw new Error('Expected signer context action to throw'); + } catch (error) { + expect(error).toBeInstanceOf(SignerContextError); + expect((error as SignerContextError).code).toBe(expectedCode); + } + }; + + beforeEach(() => { + clearSignerEnv(); + }); + + afterEach(() => { + clearSignerEnv(); if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = null; @@ -72,4 +100,85 @@ describe('lib/signer-context', () => { expect(resolved.provider).toBe('env'); expect(resolved.privateKey).toBe(wallet.privateKey); }); + + test('context mode throws invalid when wallet file is missing', () => { + tempDir = mkdtempSync(join(tmpdir(), 'aelf-node-signer-invalid-')); + process.env.PORTKEY_SKILL_WALLET_CONTEXT_PATH = join(tempDir, 'context.v1.json'); + setActiveWalletProfile( + { + walletType: 'EOA', + source: 'eoa-local', + address: 'ELF_missing_AELF', + walletFile: join(tempDir, 'missing-wallet.json'), + }, + { skill: 'test', version: '0.0.0' }, + ); + process.env.PORTKEY_WALLET_PASSWORD = 'secret'; + expectSignerErrorCode( + () => resolvePrivateKeyContext({ signerMode: 'context' }), + 'SIGNER_CONTEXT_INVALID', + ); + }); + + test('resolves active CA context with password env', () => { + tempDir = mkdtempSync(join(tmpdir(), 'aelf-node-signer-ca-')); + const contextPath = join(tempDir, 'context.v1.json'); + const keystoreFile = join(tempDir, 'ca.keystore.json'); + process.env.PORTKEY_SKILL_WALLET_CONTEXT_PATH = contextPath; + + const password = 'ca-secret'; + const managerWallet = AElf.wallet.createNewWallet(); + const keystore = getKeystore( + { + privateKey: managerWallet.privateKey, + mnemonic: managerWallet.mnemonic, + address: managerWallet.address, + }, + password, + ) as Record; + writeFileSync( + keystoreFile, + JSON.stringify( + { + caHash: 'ca_hash_1', + caAddress: 'ELF_ca_1_AELF', + keystore, + }, + null, + 2, + ), + ); + + setActiveWalletProfile( + { + walletType: 'CA', + source: 'ca-keystore', + network: 'mainnet', + address: managerWallet.address, + caAddress: 'ELF_ca_1_AELF', + caHash: 'ca_hash_1', + keystoreFile, + }, + { skill: 'test', version: '0.0.0' }, + ); + + process.env.PORTKEY_CA_KEYSTORE_PASSWORD = password; + const resolved = resolvePrivateKeyContext({ signerMode: 'context' }); + expect(resolved.provider).toBe('context'); + expect(resolved.identity.walletType).toBe('CA'); + }); + + test('daemon mode reports not implemented', () => { + expectSignerErrorCode( + () => resolvePrivateKeyContext({ signerMode: 'daemon' }), + 'SIGNER_DAEMON_NOT_IMPLEMENTED', + ); + }); + + test('returns SIGNER_CONTEXT_NOT_FOUND when explicit/context/env all unavailable', () => { + expectSignerErrorCode( + () => resolvePrivateKeyContext({ signerMode: 'auto' }), + 'SIGNER_CONTEXT_NOT_FOUND', + ); + }); }); From 03466eb4897bfc8210d116f1055433f0a91c29ae Mon Sep 17 00:00:00 2001 From: aelf-hzz780 Date: Wed, 4 Mar 2026 22:16:22 +0800 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20centralize?= =?UTF-8?q?=20signer=20error=20constants=20in=20node=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/signer-context.ts | 29 ++++++++++++++++++----------- lib/signer-error-codes.ts | 10 ++++++++++ lib/wallet-context.ts | 5 +++-- 3 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 lib/signer-error-codes.ts diff --git a/lib/signer-context.ts b/lib/signer-context.ts index 4a36eae..03c0d42 100644 --- a/lib/signer-context.ts +++ b/lib/signer-context.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import AElf from 'aelf-sdk'; import { unlockKeystore } from 'aelf-sdk/src/util/keyStore.js'; +import { SIGNER_ERROR_CODES } from './signer-error-codes.js'; import { getActiveWalletProfile, type SignerContextInput, @@ -48,20 +49,23 @@ function resolveExplicit(input: SignerContextInput): ResolvedPrivateKeyContext | function resolveContext(input: SignerContextInput): ResolvedPrivateKeyContext { const profile = getActiveWalletProfile(); if (!profile) { - throw new SignerContextError('SIGNER_CONTEXT_NOT_FOUND', 'active wallet context not found'); + throw new SignerContextError( + SIGNER_ERROR_CODES.CONTEXT_NOT_FOUND, + 'active wallet context not found', + ); } if (profile.walletType === 'EOA') { const password = input.password || process.env.PORTKEY_WALLET_PASSWORD; if (!password) { throw new SignerContextError( - 'SIGNER_PASSWORD_REQUIRED', + SIGNER_ERROR_CODES.PASSWORD_REQUIRED, 'password required for active EOA wallet (set PORTKEY_WALLET_PASSWORD or pass signer.password)', ); } if (!profile.walletFile || !existsSync(profile.walletFile)) { throw new SignerContextError( - 'SIGNER_CONTEXT_INVALID', + SIGNER_ERROR_CODES.CONTEXT_INVALID, `active EOA wallet file not found: ${profile.walletFile || ''}`, ); } @@ -70,14 +74,14 @@ function resolveContext(input: SignerContextInput): ResolvedPrivateKeyContext { typeof raw.AESEncryptPrivateKey === 'string' ? raw.AESEncryptPrivateKey : ''; if (!encrypted) { throw new SignerContextError( - 'SIGNER_CONTEXT_INVALID', + SIGNER_ERROR_CODES.CONTEXT_INVALID, 'active EOA wallet file missing AESEncryptPrivateKey', ); } const privateKey = AElf.wallet.AESDecrypt(encrypted, password); if (!privateKey) { throw new SignerContextError( - 'SIGNER_PASSWORD_REQUIRED', + SIGNER_ERROR_CODES.PASSWORD_REQUIRED, 'failed to decrypt active EOA wallet: wrong password or corrupted file', ); } @@ -95,13 +99,13 @@ function resolveContext(input: SignerContextInput): ResolvedPrivateKeyContext { const password = input.password || process.env.PORTKEY_CA_KEYSTORE_PASSWORD; if (!password) { throw new SignerContextError( - 'SIGNER_PASSWORD_REQUIRED', + SIGNER_ERROR_CODES.PASSWORD_REQUIRED, 'password required for active CA keystore (set PORTKEY_CA_KEYSTORE_PASSWORD or pass signer.password)', ); } if (!profile.keystoreFile || !existsSync(profile.keystoreFile)) { throw new SignerContextError( - 'SIGNER_CONTEXT_INVALID', + SIGNER_ERROR_CODES.CONTEXT_INVALID, `active CA keystore not found: ${profile.keystoreFile || ''}`, ); } @@ -109,7 +113,7 @@ function resolveContext(input: SignerContextInput): ResolvedPrivateKeyContext { const decrypted = unlockKeystore(raw.keystore, password); if (!decrypted?.privateKey) { throw new SignerContextError( - 'SIGNER_PASSWORD_REQUIRED', + SIGNER_ERROR_CODES.PASSWORD_REQUIRED, 'failed to decrypt active CA keystore: wrong password or corrupted file', ); } @@ -135,7 +139,7 @@ export function resolvePrivateKeyContext( if (mode === 'daemon') { throw new SignerContextError( - 'SIGNER_DAEMON_NOT_IMPLEMENTED', + SIGNER_ERROR_CODES.DAEMON_NOT_IMPLEMENTED, 'daemon signer provider is reserved for future release', ); } @@ -175,7 +179,10 @@ export function resolvePrivateKeyContext( }; } if (mode === 'env') { - throw new SignerContextError('SIGNER_CONTEXT_NOT_FOUND', 'no private key available from env'); + throw new SignerContextError( + SIGNER_ERROR_CODES.CONTEXT_NOT_FOUND, + 'no private key available from env', + ); } } @@ -184,7 +191,7 @@ export function resolvePrivateKeyContext( } throw new SignerContextError( - 'SIGNER_CONTEXT_NOT_FOUND', + SIGNER_ERROR_CODES.CONTEXT_NOT_FOUND, 'no signer available from explicit/context/env', ); } diff --git a/lib/signer-error-codes.ts b/lib/signer-error-codes.ts new file mode 100644 index 0000000..b3dbc0d --- /dev/null +++ b/lib/signer-error-codes.ts @@ -0,0 +1,10 @@ +export const SIGNER_ERROR_CODES = { + CONTEXT_NOT_FOUND: 'SIGNER_CONTEXT_NOT_FOUND', + PASSWORD_REQUIRED: 'SIGNER_PASSWORD_REQUIRED', + CONTEXT_INVALID: 'SIGNER_CONTEXT_INVALID', + DAEMON_NOT_IMPLEMENTED: 'SIGNER_DAEMON_NOT_IMPLEMENTED', + CONTEXT_LOCK_TIMEOUT: 'SIGNER_CONTEXT_LOCK_TIMEOUT', +} as const; + +export type SignerErrorCode = + (typeof SIGNER_ERROR_CODES)[keyof typeof SIGNER_ERROR_CODES]; diff --git a/lib/wallet-context.ts b/lib/wallet-context.ts index e86ce9b..c6392cc 100644 --- a/lib/wallet-context.ts +++ b/lib/wallet-context.ts @@ -12,6 +12,7 @@ import { } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; +import { SIGNER_ERROR_CODES } from './signer-error-codes.js'; export type WalletType = 'EOA' | 'CA'; export type WalletSource = 'eoa-local' | 'ca-keystore' | 'env'; @@ -230,7 +231,7 @@ function withContextLock(filePath: string, action: () => T): T { retries += 1; if (retries > LOCK_MAX_RETRIES) { throw new Error( - `SIGNER_CONTEXT_LOCK_TIMEOUT: failed to acquire context lock after ${LOCK_MAX_RETRIES} retries`, + `${SIGNER_ERROR_CODES.CONTEXT_LOCK_TIMEOUT}: failed to acquire context lock after ${LOCK_MAX_RETRIES} retries`, ); } sleepMs(LOCK_RETRY_INTERVAL_MS); @@ -238,7 +239,7 @@ function withContextLock(filePath: string, action: () => T): T { } throw new Error( - `SIGNER_CONTEXT_LOCK_TIMEOUT: failed to acquire context lock after ${LOCK_MAX_RETRIES} retries`, + `${SIGNER_ERROR_CODES.CONTEXT_LOCK_TIMEOUT}: failed to acquire context lock after ${LOCK_MAX_RETRIES} retries`, ); } From 9f22b94e045886abf680cfcd9f118e06f0cf6609 Mon Sep 17 00:00:00 2001 From: aelf-hzz780 Date: Wed, 4 Mar 2026 23:43:21 +0800 Subject: [PATCH 4/5] =?UTF-8?q?chore(=F0=9F=A7=B1):=20finalize=20type-safe?= =?UTF-8?q?ty=20and=20coverage=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 3 + bun.lock | 5 +- lib/aelf-sdk.d.ts | 19 ++++ package.json | 4 + tests/unit/errors-normalize.test.ts | 39 ++++++- tests/unit/response-shape.test.ts | 4 +- tests/unit/rest-client.test.ts | 10 +- tests/unit/wallet-context.test.ts | 158 ++++++++++++++++++++++++++++ tsconfig.base.json | 18 ++++ tsconfig.json | 18 +--- tsconfig.src.json | 17 +++ tsconfig.test.json | 18 ++++ 12 files changed, 287 insertions(+), 26 deletions(-) create mode 100644 tests/unit/wallet-context.test.ts create mode 100644 tsconfig.base.json create mode 100644 tsconfig.src.json create mode 100644 tsconfig.test.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 67c1049..3be3c8a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,9 @@ jobs: - run: bun install - run: bun run deps:check + - name: Typecheck (observe) + continue-on-error: true + run: bun run typecheck - run: bun run test:coverage:ci - run: bun run build:openclaw:check diff --git a/bun.lock b/bun.lock index 7d55c01..14d5108 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ }, "devDependencies": { "@types/bun": "latest", + "@types/node": "latest", "ajv": "^8.17.1", "typescript": "^5.7.0", }, @@ -142,7 +143,7 @@ "@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="], - "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@5.62.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", "@typescript-eslint/typescript-estree": "5.62.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA=="], @@ -702,6 +703,8 @@ "bip39/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "bun-types/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "elliptic/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], "eslint/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], diff --git a/lib/aelf-sdk.d.ts b/lib/aelf-sdk.d.ts index b7358d6..d7932fb 100644 --- a/lib/aelf-sdk.d.ts +++ b/lib/aelf-sdk.d.ts @@ -1,6 +1,8 @@ declare module 'aelf-sdk' { export interface AelfWallet { address: string; + privateKey: string; + mnemonic?: string; [key: string]: unknown; } @@ -36,6 +38,8 @@ declare module 'aelf-sdk' { export interface AelfWalletApi { createNewWallet(): AelfWallet; getWalletByPrivateKey(privateKey: string): AelfWallet; + AESEncrypt(privateKey: string, password: string): string; + AESDecrypt(encrypted: string, password: string): string; } export interface AelfStaticApi { @@ -53,3 +57,18 @@ declare module 'aelf-sdk' { export default AElf; } + +declare module "aelf-sdk/src/util/keyStore.js" { + export function getKeystore( + account: { privateKey: string; mnemonic?: string; address?: string; nickName?: string }, + password: string, + option?: Record, + ): unknown; + export function unlockKeystore(keystore: unknown, password: string): { + privateKey: string; + mnemonic?: string; + address?: string; + nickName?: string; + }; + export function checkPassword(keystore: unknown, password: string): boolean; +} diff --git a/package.json b/package.json index db64d7c..e97b4ea 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,9 @@ "test:live": "RUN_LIVE_TESTS=1 bun test tests/integration/", "coverage:gate": "bun run scripts/coverage-gate.ts", "test:coverage:ci": "COVERAGE_MIN_LINES=85 COVERAGE_MIN_FUNCS=80 bun run test:unit:coverage && bun run coverage:gate", + "typecheck:src": "bunx tsc -p tsconfig.src.json --noEmit", + "typecheck:test": "bunx tsc -p tsconfig.test.json --noEmit", + "typecheck": "bun run typecheck:src && bun run typecheck:test", "build:openclaw": "bun run bin/generate-openclaw.ts", "build:openclaw:check": "bun run bin/generate-openclaw.ts --check", "coverage:badge": "bun run bin/generate-coverage-badge.ts", @@ -71,6 +74,7 @@ }, "devDependencies": { "@types/bun": "latest", + "@types/node": "latest", "ajv": "^8.17.1", "typescript": "^5.7.0" } diff --git a/tests/unit/errors-normalize.test.ts b/tests/unit/errors-normalize.test.ts index ccf4e52..88d7c1e 100644 --- a/tests/unit/errors-normalize.test.ts +++ b/tests/unit/errors-normalize.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { normalizeError } from '../../lib/errors.js'; +import { HttpStatusError, normalizeError } from '../../lib/errors.js'; describe('lib/errors normalizeError', () => { test('preserves explicit error code on Error-like input', () => { @@ -12,4 +12,41 @@ describe('lib/errors normalizeError', () => { expect(normalized.message).toBe('missing signer context'); expect(normalized.details).toBe('active profile missing'); }); + + test('normalizes HttpStatusError with backend Error payload', () => { + const err = new HttpStatusError(502, { + Error: { + Code: 'NODE_UNAVAILABLE', + Message: 'upstream timeout', + Details: 'rpc failed', + }, + }); + const normalized = normalizeError(err, 'QUERY_FAILED'); + expect(normalized.code).toBe('NODE_UNAVAILABLE'); + expect(normalized.message).toBe('upstream timeout'); + expect(normalized.details).toBe('rpc failed'); + expect(normalized.httpStatus).toBe(502); + }); + + test('falls back when Error.code is not a string', () => { + const err = new Error('bad input') as Error & { code?: number; details?: unknown }; + err.code = 400; + err.details = { field: 'chainId' }; + + const normalized = normalizeError(err, 'VALIDATION_FAILED'); + expect(normalized.code).toBe('VALIDATION_FAILED'); + expect(normalized.message).toBe('bad input'); + expect(normalized.details).toContain('chainId'); + }); + + test('handles non-Error input values', () => { + const normalizedString = normalizeError('raw-failure', 'SEND_FAILED'); + expect(normalizedString.code).toBe('SEND_FAILED'); + expect(normalizedString.message).toBe('Unhandled error'); + expect(normalizedString.details).toBe('raw-failure'); + + const normalizedNull = normalizeError(null, 'SEND_FAILED'); + expect(normalizedNull.code).toBe('SEND_FAILED'); + expect(normalizedNull.details).toBe(''); + }); }); diff --git a/tests/unit/response-shape.test.ts b/tests/unit/response-shape.test.ts index 85da072..2b5a95f 100644 --- a/tests/unit/response-shape.test.ts +++ b/tests/unit/response-shape.test.ts @@ -9,7 +9,7 @@ afterEach(() => { describe('response shape', () => { it('always returns traceId and ok fields', async () => { - globalThis.fetch = (async () => { + globalThis.fetch = ((async () => { return new Response( JSON.stringify({ BestChainHash: '0xabc', @@ -17,7 +17,7 @@ describe('response shape', () => { }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ); - }) as typeof fetch; + }) as unknown) as typeof fetch; const result = await getChainStatus({ rpcUrl: 'https://mock-node.test' }); expect(typeof result.ok).toBe('boolean'); diff --git a/tests/unit/rest-client.test.ts b/tests/unit/rest-client.test.ts index fc5d7de..be2094c 100644 --- a/tests/unit/rest-client.test.ts +++ b/tests/unit/rest-client.test.ts @@ -9,7 +9,7 @@ afterEach(() => { describe('lib/rest-client', () => { it('keeps plain numeric text as string', async () => { - globalThis.fetch = (async () => new Response('12345678901234567890', { status: 200 })) as typeof fetch; + globalThis.fetch = ((async () => new Response('12345678901234567890', { status: 200 })) as unknown) as typeof fetch; const client = new RestClient('https://mock-node.test', 100, 0); const result = await client.request({ method: 'GET', path: 'blockChain/blockHeight' }); @@ -20,7 +20,7 @@ describe('lib/rest-client', () => { it('retries failed request before succeeding', async () => { let calls = 0; - globalThis.fetch = (async () => { + globalThis.fetch = ((async () => { calls += 1; if (calls === 1) { return new Response(JSON.stringify({ Error: { Code: 'TEMP', Message: 'temporary' } }), { @@ -32,7 +32,7 @@ describe('lib/rest-client', () => { status: 200, headers: { 'Content-Type': 'application/json' }, }); - }) as typeof fetch; + }) as unknown) as typeof fetch; const client = new RestClient('https://mock-node.test', 100, 1); const result = await client.request<{ ok: boolean }>({ method: 'GET', path: 'blockChain/chainStatus' }); @@ -42,13 +42,13 @@ describe('lib/rest-client', () => { }); it('aborts request on timeout', async () => { - globalThis.fetch = ((_: RequestInfo | URL, init?: RequestInit) => + globalThis.fetch = (((_: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { const signal = init?.signal; signal?.addEventListener('abort', () => { reject(new Error('aborted')); }); - })) as typeof fetch; + })) as unknown) as typeof fetch; const client = new RestClient('https://mock-node.test', 5, 0); await expect(client.request({ method: 'GET', path: 'blockChain/chainStatus' })).rejects.toThrow('aborted'); diff --git a/tests/unit/wallet-context.test.ts b/tests/unit/wallet-context.test.ts new file mode 100644 index 0000000..fc85f54 --- /dev/null +++ b/tests/unit/wallet-context.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + readWalletContext, + setActiveWalletProfile, +} from '../../lib/wallet-context.js'; + +describe('wallet context storage', () => { + let tempDir = ''; + let contextPath = ''; + + const writer = { + skill: 'unit-test', + version: '0.0.0', + }; + + beforeEach(() => { + tempDir = join(tmpdir(), `aelf-node-wallet-context-${Date.now()}-${Math.random()}`); + contextPath = join(tempDir, 'context.v1.json'); + process.env.PORTKEY_SKILL_WALLET_CONTEXT_PATH = contextPath; + }); + + afterEach(() => { + delete process.env.PORTKEY_SKILL_WALLET_CONTEXT_PATH; + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('readWalletContext returns null when file is missing', () => { + expect(readWalletContext()).toBeNull(); + }); + + test('readWalletContext returns null when file contains invalid JSON', () => { + mkdirSync(tempDir, { recursive: true }); + writeFileSync(contextPath, '{bad-json', 'utf8'); + expect(readWalletContext()).toBeNull(); + }); + + test('setActiveWalletProfile creates directory and context file', () => { + const result = setActiveWalletProfile( + { + walletType: 'EOA', + source: 'eoa-local', + address: 'ELF_node_AELF', + walletFile: '/tmp/wallet.json', + }, + writer, + ); + expect(result.activeProfileId).toBe('default'); + expect(existsSync(contextPath)).toBeTrue(); + expect(readWalletContext()?.profiles.default?.address).toBe('ELF_node_AELF'); + }); + + test('writes secure file permissions on unix-like platforms', () => { + setActiveWalletProfile( + { + walletType: 'EOA', + source: 'eoa-local', + address: 'ELF_perm_AELF', + walletFile: '/tmp/wallet.json', + }, + writer, + ); + if (process.platform === 'win32') return; + + const dirMode = statSync(tempDir).mode & 0o777; + const fileMode = statSync(contextPath).mode & 0o777; + expect(dirMode).toBe(0o700); + expect(fileMode).toBe(0o600); + }); + + test('throws SIGNER_CONTEXT_LOCK_TIMEOUT when lock is fresh and never released', () => { + mkdirSync(tempDir, { recursive: true }); + const lockPath = `${contextPath}.lock`; + writeFileSync(lockPath, 'locked', 'utf8'); + + expect(() => + setActiveWalletProfile( + { + walletType: 'EOA', + source: 'eoa-local', + address: 'ELF_lock_AELF', + walletFile: '/tmp/wallet.json', + }, + writer, + ), + ).toThrow('SIGNER_CONTEXT_LOCK_TIMEOUT'); + }); + + test('cleans stale lock and continues writing', () => { + mkdirSync(tempDir, { recursive: true }); + const lockPath = `${contextPath}.lock`; + writeFileSync(lockPath, 'old-lock', 'utf8'); + const staleAt = new Date(Date.now() - 60_000); + utimesSync(lockPath, staleAt, staleAt); + + const result = setActiveWalletProfile( + { + walletType: 'EOA', + source: 'eoa-local', + address: 'ELF_stale_AELF', + walletFile: '/tmp/wallet.json', + }, + writer, + ); + expect(result.profiles.default?.address).toBe('ELF_stale_AELF'); + expect(existsSync(lockPath)).toBeFalse(); + }); + + test('context file does not include plaintext private key', () => { + setActiveWalletProfile( + { + walletType: 'EOA', + source: 'eoa-local', + address: 'ELF_safe_AELF', + walletFile: '/tmp/wallet.json', + }, + writer, + ); + const raw = readFileSync(contextPath, 'utf8'); + expect(raw.includes('privateKey')).toBeFalse(); + }); + + test('last write wins and lastWriter is updated', () => { + setActiveWalletProfile( + { + walletType: 'EOA', + source: 'eoa-local', + address: 'ELF_first_AELF', + walletFile: '/tmp/first.json', + }, + writer, + ); + const secondWriter = { skill: 'unit-test-2', version: '0.0.1' }; + const second = setActiveWalletProfile( + { + walletType: 'EOA', + source: 'eoa-local', + address: 'ELF_second_AELF', + walletFile: '/tmp/second.json', + }, + secondWriter, + ); + expect(second.profiles.default?.address).toBe('ELF_second_AELF'); + expect(second.lastWriter.skill).toBe('unit-test-2'); + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..745fc48 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "types": [ + "bun-types", + "node" + ] + } +} diff --git a/tsconfig.json b/tsconfig.json index e5c0303..cd293dd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,19 +1,3 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "types": [ - "bun-types" - ], - "noEmit": true - }, - "include": [ - "**/*.ts" - ] + "extends": "./tsconfig.src.json" } diff --git a/tsconfig.src.json b/tsconfig.src.json new file mode 100644 index 0000000..37e3530 --- /dev/null +++ b/tsconfig.src.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.base.json", + "include": [ + "index.ts", + "aelf_node_skill.ts", + "src/**/*.ts", + "lib/**/*.ts", + "lib/**/*.d.ts", + "types/**/*.d.ts", + "bin/**/*.ts" + ], + "exclude": [ + "node_modules", + "coverage", + "tests" + ] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..c6c8d79 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.base.json", + "include": [ + "index.ts", + "aelf_node_skill.ts", + "src/**/*.ts", + "lib/**/*.ts", + "lib/**/*.d.ts", + "types/**/*.d.ts", + "bin/**/*.ts", + "scripts/**/*.ts", + "tests/**/*.ts" + ], + "exclude": [ + "node_modules", + "coverage" + ] +} From 5d24c91dc49c5a667068113bb693f55b2ceb01b8 Mon Sep 17 00:00:00 2001 From: aelf-hzz780 Date: Wed, 4 Mar 2026 23:53:41 +0800 Subject: [PATCH 5/5] =?UTF-8?q?chore(=F0=9F=93=A6):=20bump=20package=20ver?= =?UTF-8?q?sion=20for=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e97b4ea..20cd3c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockchain-forever/aelf-node-skill", - "version": "0.1.1", + "version": "0.1.2", "description": "AElf Node Skill for AI agents: REST for reads, SDK for contract execution, and selective fallback for fee estimate.", "type": "module", "main": "index.ts",