From 50eb0ff6718a39fd4c95e276be97ca65a4903af8 Mon Sep 17 00:00:00 2001 From: aelf-hzz780 Date: Thu, 26 Feb 2026 20:23:40 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor(core):=20=E2=99=BB=EF=B8=8F=20hard?= =?UTF-8?q?en=20typing,=20security,=20caching,=20and=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 + .github/workflows/publish.yml | 2 +- README.md | 6 +- README.zh-CN.md | 6 +- bin/check-core-coverage.ts | 2 +- index.ts | 2 +- lib/aelf-sdk.d.ts | 53 ++++++- lib/config.ts | 23 ++- lib/node-registry.ts | 70 ++++++--- lib/node-router.ts | 3 + lib/rest-client.ts | 25 ++-- lib/sdk-client.ts | 104 ++++++++++---- lib/signer.ts | 8 +- lib/utils/lru.ts | 49 +++++++ lib/utils/time.ts | 3 + lib/validators.ts | 80 +++++++++++ package.json | 2 +- src/core/contract.ts | 9 ++ src/core/node-registry.ts | 6 +- src/core/query.ts | 46 +++++- src/mcp/server.ts | 9 +- tests/unit/contract-core.test.ts | 184 ++++++++++++++++++++++++ tests/unit/contract-path-compat.test.ts | 3 +- tests/unit/mcp-server-schema.test.ts | 13 ++ tests/unit/node-registry-core.test.ts | 70 +++++++++ tests/unit/query-core.test.ts | 7 +- tests/unit/rest-client.test.ts | 56 ++++++++ tests/unit/sdk-client.test.ts | 38 +++++ tests/unit/signer.test.ts | 15 ++ tests/unit/validators.test.ts | 26 ++++ 30 files changed, 846 insertions(+), 79 deletions(-) create mode 100644 lib/utils/lru.ts create mode 100644 lib/utils/time.ts create mode 100644 lib/validators.ts create mode 100644 tests/unit/contract-core.test.ts create mode 100644 tests/unit/mcp-server-schema.test.ts create mode 100644 tests/unit/node-registry-core.test.ts create mode 100644 tests/unit/rest-client.test.ts create mode 100644 tests/unit/sdk-client.test.ts create mode 100644 tests/unit/signer.test.ts create mode 100644 tests/unit/validators.test.ts diff --git a/.env.example b/.env.example index e3ad2f3..9742db1 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,8 @@ AELF_NODE_TIMEOUT_MS=10000 # Optional retries for REST calls AELF_NODE_RETRY=1 + +# Optional cache limits +AELF_SDK_INSTANCE_CACHE_MAX=32 +AELF_SDK_CONTRACT_CACHE_MAX=256 +AELF_REST_CLIENT_CACHE_MAX=64 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 806de80..20879f8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,7 +27,7 @@ jobs: - run: bun run test:unit:coverage:gate env: - CORE_COVERAGE_THRESHOLD: '70' + CORE_COVERAGE_THRESHOLD: '80' - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/README.md b/README.md index 7553a9e..522b5c2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [English](./README.md) | [中文](./README.zh-CN.md) -AElf Node Skill provides MCP, CLI, and SDK interfaces for AElf public nodes with a `SDK-first + REST fallback` architecture. +AElf Node Skill provides MCP, CLI, and SDK interfaces for AElf public nodes with `REST for reads, SDK for contract execution, and selective fallback for fee estimate`. ## Features @@ -85,9 +85,13 @@ 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_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 +- `AELF_SDK_INSTANCE_CACHE_MAX`: optional max SDK instance cache size (default `32`) +- `AELF_SDK_CONTRACT_CACHE_MAX`: optional max SDK contract cache size (default `256`) +- `AELF_REST_CLIENT_CACHE_MAX`: optional max REST client cache size (default `64`) ## Tool List diff --git a/README.zh-CN.md b/README.zh-CN.md index cb656d2..31aa085 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,7 @@ [中文](./README.zh-CN.md) | [English](./README.md) -AElf Node Skill 提供 MCP、CLI、SDK 三种接口,基于 `SDK-first + REST fallback` 架构访问 AElf 公共节点。 +AElf Node Skill 提供 MCP、CLI、SDK 三种接口,采用“读走 REST、合约执行走 SDK、手续费估算选择性 fallback”的架构访问 AElf 公共节点。 ## 功能 @@ -85,9 +85,13 @@ cp .env.example .env ``` - `AELF_PRIVATE_KEY`:写操作必填 +- MCP 模式仅从环境变量读取 `AELF_PRIVATE_KEY`(不接受 tool 入参传私钥) - `AELF_NODE_AELF_RPC_URL`:可选,覆盖 AELF 节点 - `AELF_NODE_TDVV_RPC_URL`:可选,覆盖 tDVV 节点 - `AELF_NODE_REGISTRY_PATH`:可选,自定义节点注册表路径 +- `AELF_SDK_INSTANCE_CACHE_MAX`:可选,SDK 实例缓存上限(默认 `32`) +- `AELF_SDK_CONTRACT_CACHE_MAX`:可选,SDK 合约缓存上限(默认 `256`) +- `AELF_REST_CLIENT_CACHE_MAX`:可选,REST 客户端缓存上限(默认 `64`) ## Tool 列表 diff --git a/bin/check-core-coverage.ts b/bin/check-core-coverage.ts index c06a963..f6d495e 100644 --- a/bin/check-core-coverage.ts +++ b/bin/check-core-coverage.ts @@ -46,7 +46,7 @@ function parseCoreLineHits(lcovText: string): LineHits { function main() { const lcovFile = process.env.CORE_COVERAGE_FILE || 'coverage/lcov.info'; - const threshold = Number(process.env.CORE_COVERAGE_THRESHOLD || '70'); + const threshold = Number(process.env.CORE_COVERAGE_THRESHOLD || '80'); const lcovPath = resolve(process.cwd(), lcovFile); if (!existsSync(lcovPath)) { diff --git a/index.ts b/index.ts index 8d1af91..8ac9503 100644 --- a/index.ts +++ b/index.ts @@ -13,7 +13,7 @@ export { callContractView, sendContractTransaction } from './src/core/contract.j export { importNode, listNodes } from './src/core/node-registry.js'; export { resolveNode, listAvailableNodes } from './lib/node-router.js'; -export { clearSdkCaches } from './lib/sdk-client.js'; +export { clearSdkCaches, clearSdkCacheForRpc } from './lib/sdk-client.js'; export type { SkillResponse, SkillError, diff --git a/lib/aelf-sdk.d.ts b/lib/aelf-sdk.d.ts index 12d3e93..b7358d6 100644 --- a/lib/aelf-sdk.d.ts +++ b/lib/aelf-sdk.d.ts @@ -1,4 +1,55 @@ declare module 'aelf-sdk' { - const AElf: any; + export interface AelfWallet { + address: string; + [key: string]: unknown; + } + + export interface AelfTxResult { + Status?: string; + [key: string]: unknown; + } + + export interface AelfContractMethod { + (params?: Record): Promise; + call?: (params?: Record) => Promise; + getSignedTx?: (params?: Record) => string; + } + + export interface AelfContract { + [methodName: string]: AelfContractMethod | unknown; + } + + export interface AelfChainApi { + contractAt(contractAddress: string, wallet: AelfWallet): Promise; + getTxResult(transactionId: string): Promise; + calculateTransactionFee(rawTransaction: string): Promise; + } + + export interface AelfInstance { + chain: AelfChainApi; + } + + export interface HttpProviderConstructor { + new (rpcUrl: string, timeoutMs?: number): unknown; + } + + export interface AelfWalletApi { + createNewWallet(): AelfWallet; + getWalletByPrivateKey(privateKey: string): AelfWallet; + } + + export interface AelfStaticApi { + providers: { + HttpProvider: HttpProviderConstructor; + }; + wallet: AelfWalletApi; + } + + export interface AelfConstructor { + new (provider: unknown): AelfInstance; + } + + const AElf: AelfConstructor & AelfStaticApi; + export default AElf; } diff --git a/lib/config.ts b/lib/config.ts index f68a74e..e8daaa5 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -4,6 +4,9 @@ import type { NodeProfile } from './types.js'; const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_RETRY = 1; +const DEFAULT_SDK_INSTANCE_CACHE_MAX = 32; +const DEFAULT_SDK_CONTRACT_CACHE_MAX = 256; +const DEFAULT_REST_CLIENT_CACHE_MAX = 64; export const DEFAULT_NODES: NodeProfile[] = [ { @@ -22,9 +25,13 @@ export const DEFAULT_NODES: NodeProfile[] = [ }, ]; +function getPositiveIntFromEnv(name: string, defaultValue: number): number { + const value = Number(process.env[name]); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : defaultValue; +} + export function getTimeoutMs(): number { - const value = Number(process.env.AELF_NODE_TIMEOUT_MS); - return Number.isFinite(value) && value > 0 ? value : DEFAULT_TIMEOUT_MS; + return getPositiveIntFromEnv('AELF_NODE_TIMEOUT_MS', DEFAULT_TIMEOUT_MS); } export function getRetryCount(): number { @@ -32,6 +39,18 @@ export function getRetryCount(): number { return Number.isFinite(value) && value >= 0 ? Math.floor(value) : DEFAULT_RETRY; } +export function getSdkInstanceCacheMax(): number { + return getPositiveIntFromEnv('AELF_SDK_INSTANCE_CACHE_MAX', DEFAULT_SDK_INSTANCE_CACHE_MAX); +} + +export function getSdkContractCacheMax(): number { + return getPositiveIntFromEnv('AELF_SDK_CONTRACT_CACHE_MAX', DEFAULT_SDK_CONTRACT_CACHE_MAX); +} + +export function getRestClientCacheMax(): number { + return getPositiveIntFromEnv('AELF_REST_CLIENT_CACHE_MAX', DEFAULT_REST_CLIENT_CACHE_MAX); +} + export function getRegistryPath(): string { return process.env.AELF_NODE_REGISTRY_PATH || join(homedir(), '.aelf-node-skill', 'nodes.json'); } diff --git a/lib/node-registry.ts b/lib/node-registry.ts index f025e1f..b5470f5 100644 --- a/lib/node-registry.ts +++ b/lib/node-registry.ts @@ -1,4 +1,5 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { getRegistryPath } from './config.js'; import type { ImportNodeInput, NodeProfile, NodeRegistryFile } from './types.js'; @@ -22,6 +23,31 @@ function normalizeNode(node: NodeProfile): NodeProfile { }; } +let registryLock: Promise = Promise.resolve(); + +async function withRegistryLock(action: () => Promise): Promise { + const previous = registryLock; + let release: (() => void) | undefined; + + registryLock = new Promise(resolve => { + release = resolve; + }); + + await previous; + + try { + return await action(); + } finally { + release?.(); + } +} + +async function writeRegistryAtomic(path: string, content: string): Promise { + const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(tempPath, content, 'utf8'); + await rename(tempPath, path); +} + export async function readNodeRegistry(): Promise { const path = getRegistryPath(); try { @@ -42,7 +68,7 @@ export async function readNodeRegistry(): Promise { export async function writeNodeRegistry(file: NodeRegistryFile): Promise { const path = getRegistryPath(); await mkdir(dirname(path), { recursive: true }); - await writeFile(path, `${JSON.stringify(file, null, 2)}\n`, 'utf8'); + await writeRegistryAtomic(path, `${JSON.stringify(file, null, 2)}\n`); } export async function listImportedNodes(): Promise { @@ -51,25 +77,27 @@ export async function listImportedNodes(): Promise { } export async function importNode(input: ImportNodeInput): Promise { - const file = await readNodeRegistry(); - const nextNode: NodeProfile = normalizeNode({ - id: input.id, - chainId: input.chainId, - rpcUrl: input.rpcUrl, - enabled: input.enabled !== false, - source: 'imported', - updatedAt: nowIso(), - createdAt: nowIso(), - }); + return withRegistryLock(async () => { + const file = await readNodeRegistry(); + const nextNode: NodeProfile = normalizeNode({ + id: input.id, + chainId: input.chainId, + rpcUrl: input.rpcUrl, + enabled: input.enabled !== false, + source: 'imported', + updatedAt: nowIso(), + createdAt: nowIso(), + }); - const index = file.nodes.findIndex(node => node.id === nextNode.id); - if (index >= 0) { - const createdAt = file.nodes[index].createdAt || nowIso(); - file.nodes[index] = { ...nextNode, createdAt, updatedAt: nowIso() }; - } else { - file.nodes.push(nextNode); - } + const index = file.nodes.findIndex(node => node.id === nextNode.id); + if (index >= 0) { + const createdAt = file.nodes[index].createdAt || nowIso(); + file.nodes[index] = { ...nextNode, createdAt, updatedAt: nowIso() }; + } else { + file.nodes.push(nextNode); + } - await writeNodeRegistry(file); - return nextNode; + await writeNodeRegistry(file); + return nextNode; + }); } diff --git a/lib/node-router.ts b/lib/node-router.ts index 08345ea..73d5a73 100644 --- a/lib/node-router.ts +++ b/lib/node-router.ts @@ -1,5 +1,6 @@ import { DEFAULT_NODES, getEnvOverrideNodes } from './config.js'; import { listImportedNodes } from './node-registry.js'; +import { validateChainTargetInput } from './validators.js'; import type { NodeProfile, ResolveNodeInput, ResolveNodeResult } from './types.js'; function sanitize(input: NodeProfile[]): NodeProfile[] { @@ -14,6 +15,8 @@ export async function listAvailableNodes(): Promise { } export async function resolveNode(input: ResolveNodeInput): Promise { + validateChainTargetInput(input); + if (input.rpcUrl) { return { node: { diff --git a/lib/rest-client.ts b/lib/rest-client.ts index 651ba7a..6544cb4 100644 --- a/lib/rest-client.ts +++ b/lib/rest-client.ts @@ -1,5 +1,6 @@ import { HttpStatusError } from './errors.js'; import { getRetryCount, getTimeoutMs } from './config.js'; +import { sleep } from './utils/time.js'; export interface RestRequest { method: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH'; @@ -22,21 +23,27 @@ function toQueryString(query?: Record { - return new Promise(resolve => setTimeout(resolve, ms)); -} - export class RestClient { private readonly baseUrl: string; diff --git a/lib/sdk-client.ts b/lib/sdk-client.ts index 8d0d37c..f3d7154 100644 --- a/lib/sdk-client.ts +++ b/lib/sdk-client.ts @@ -1,37 +1,68 @@ -import AElf from 'aelf-sdk'; -import { getTimeoutMs } from './config.js'; +import AElf, { + type AelfContract, + type AelfContractMethod, + type AelfInstance, + type AelfWallet, +} from 'aelf-sdk'; +import { + getSdkContractCacheMax, + getSdkInstanceCacheMax, + getTimeoutMs, +} from './config.js'; import { EoaSigner, type Signer } from './signer.js'; import type { SendContractTransactionOutput } from './types.js'; +import { LruCache } from './utils/lru.js'; +import { sleep } from './utils/time.js'; -const instanceCache: Record = {}; -const contractCache: Record = {}; +const instanceCache = new LruCache(getSdkInstanceCacheMax()); +const contractCache = new LruCache(getSdkContractCacheMax()); +let readOnlyWallet: AelfWallet | undefined; -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); +function getContractMethod(contract: AelfContract, methodName: string): AelfContractMethod | undefined { + const method = contract?.[methodName]; + if (typeof method !== 'function') { + return undefined; + } + return method as AelfContractMethod; } -export function getAElfInstance(rpcUrl: string): any { - if (!instanceCache[rpcUrl]) { - instanceCache[rpcUrl] = new AElf(new AElf.providers.HttpProvider(rpcUrl, getTimeoutMs())); +export function getAElfInstance(rpcUrl: string): AelfInstance { + const cached = instanceCache.get(rpcUrl); + if (cached) { + return cached; } - return instanceCache[rpcUrl]; + + const instance = new AElf(new AElf.providers.HttpProvider(rpcUrl, getTimeoutMs())); + instanceCache.set(rpcUrl, instance); + return instance; } -export function getReadOnlyWallet(): any { - return AElf.wallet.createNewWallet(); +export function getReadOnlyWallet(): AelfWallet { + if (!readOnlyWallet) { + readOnlyWallet = AElf.wallet.createNewWallet(); + } + return readOnlyWallet; } export function getEoaSigner(privateKey: string): Signer { return new EoaSigner(privateKey); } -async function getContractInstance(rpcUrl: string, contractAddress: string, wallet: any): Promise { +async function getContractInstance( + rpcUrl: string, + contractAddress: string, + wallet: AelfWallet, +): Promise { const key = `${rpcUrl}|${contractAddress}|${wallet.address}`; - if (!contractCache[key]) { - const instance = getAElfInstance(rpcUrl); - contractCache[key] = await instance.chain.contractAt(contractAddress, wallet); + const cached = contractCache.get(key); + if (cached) { + return cached; } - return contractCache[key]; + + const instance = getAElfInstance(rpcUrl); + const contract = await instance.chain.contractAt(contractAddress, wallet); + contractCache.set(key, contract); + return contract; } export async function callContractView( @@ -42,14 +73,17 @@ export async function callContractView( ): Promise { const wallet = getReadOnlyWallet(); const contract = await getContractInstance(rpcUrl, contractAddress, wallet); - const method = contract?.[methodName]; + const method = getContractMethod(contract, methodName); + if (!method || typeof method.call !== 'function') { throw new Error(`View method not found: ${methodName}`); } + const result = await method.call(params); if (result && typeof result === 'object' && 'error' in result && (result as Record).error) { throw new Error(`View method failed: ${JSON.stringify((result as Record).error)}`); } + return result; } @@ -62,14 +96,17 @@ export async function buildSignedTransaction( ): Promise { const signer = getEoaSigner(privateKey); const contract = await getContractInstance(rpcUrl, contractAddress, signer.getWallet()); - const method = contract?.[methodName]; + const method = getContractMethod(contract, methodName); + if (!method || typeof method.getSignedTx !== 'function') { throw new Error(`Method getSignedTx is unavailable for ${methodName}`); } + const signedTx = method.getSignedTx(params); if (typeof signedTx !== 'string' || !signedTx) { throw new Error('Failed to build signed transaction'); } + return signedTx; } @@ -88,6 +125,7 @@ export async function pollTransactionResult( } await sleep(retryIntervalMs); } + throw new Error(`Transaction ${transactionId} not finalized after ${maxRetries} retries`); } @@ -103,13 +141,20 @@ export async function sendContractTransaction( ): Promise { const signer = getEoaSigner(privateKey); const contract = await getContractInstance(rpcUrl, contractAddress, signer.getWallet()); - const method = contract?.[methodName]; - if (!method || typeof method !== 'function') { + const method = getContractMethod(contract, methodName); + + if (!method) { throw new Error(`Send method not found: ${methodName}`); } const sendResult = await method(params); - const transactionId = sendResult?.result?.TransactionId || sendResult?.TransactionId; + const sendData = sendResult as Record; + const nestedResult = sendData.result as Record | undefined; + const transactionId = + (typeof nestedResult?.TransactionId === 'string' && nestedResult.TransactionId) || + (typeof sendData.TransactionId === 'string' && sendData.TransactionId) || + ''; + if (!transactionId) { throw new Error(`No TransactionId returned for ${methodName}`); } @@ -133,7 +178,18 @@ export async function estimateTransactionFeeBySdk( return instance.chain.calculateTransactionFee(rawTransaction); } +export function clearSdkCacheForRpc(rpcUrl: string): void { + instanceCache.delete(rpcUrl); + const prefix = `${rpcUrl}|`; + contractCache.keys().forEach(key => { + if (key.startsWith(prefix)) { + contractCache.delete(key); + } + }); +} + export function clearSdkCaches(): void { - Object.keys(instanceCache).forEach(key => delete instanceCache[key]); - Object.keys(contractCache).forEach(key => delete contractCache[key]); + readOnlyWallet = undefined; + instanceCache.clear(); + contractCache.clear(); } diff --git a/lib/signer.ts b/lib/signer.ts index 4632869..79e81ce 100644 --- a/lib/signer.ts +++ b/lib/signer.ts @@ -1,12 +1,12 @@ -import AElf from 'aelf-sdk'; +import AElf, { type AelfWallet } from 'aelf-sdk'; export interface Signer { getAddress(): string; - getWallet(): any; + getWallet(): AelfWallet; } export class EoaSigner implements Signer { - private readonly wallet: any; + private readonly wallet: AelfWallet; constructor(privateKey: string) { this.wallet = AElf.wallet.getWalletByPrivateKey(privateKey); @@ -16,7 +16,7 @@ export class EoaSigner implements Signer { return this.wallet.address; } - getWallet(): any { + getWallet(): AelfWallet { return this.wallet; } } diff --git a/lib/utils/lru.ts b/lib/utils/lru.ts new file mode 100644 index 0000000..8892329 --- /dev/null +++ b/lib/utils/lru.ts @@ -0,0 +1,49 @@ +export class LruCache { + private readonly cache = new Map(); + + private readonly maxSize: number; + + constructor(maxSize: number) { + this.maxSize = Number.isFinite(maxSize) && maxSize > 0 ? Math.floor(maxSize) : 1; + } + + get(key: K): V | undefined { + if (!this.cache.has(key)) { + return undefined; + } + + const value = this.cache.get(key) as V; + this.cache.delete(key); + this.cache.set(key, value); + return value; + } + + set(key: K, value: V): void { + if (this.cache.has(key)) { + this.cache.delete(key); + } + + this.cache.set(key, value); + + if (this.cache.size > this.maxSize) { + const oldestKey = this.cache.keys().next().value as K; + this.cache.delete(oldestKey); + } + } + + delete(key: K): boolean { + return this.cache.delete(key); + } + + clear(): void { + this.cache.clear(); + } + + keys(): K[] { + return [...this.cache.keys()]; + } + + size(): number { + return this.cache.size; + } +} diff --git a/lib/utils/time.ts b/lib/utils/time.ts new file mode 100644 index 0000000..5785ba7 --- /dev/null +++ b/lib/utils/time.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/lib/validators.ts b/lib/validators.ts new file mode 100644 index 0000000..65b5a32 --- /dev/null +++ b/lib/validators.ts @@ -0,0 +1,80 @@ +import type { ChainTargetInput, ImportNodeInput } from './types.js'; + +const CHAIN_ID_PATTERN = /^[A-Za-z][A-Za-z0-9]{0,31}$/; +const NODE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; +const CONTRACT_ADDRESS_PATTERN = /^[1-9A-HJ-NP-Za-km-z]{30,70}$/; + +function assertNonEmptyString(value: unknown, fieldName: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${fieldName} is required`); + } + + return value.trim(); +} + +export function validateRpcUrl(rpcUrl: string, fieldName = 'rpcUrl'): string { + const value = assertNonEmptyString(rpcUrl, fieldName); + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error(`${fieldName} must be a valid URL`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`${fieldName} must use http or https protocol`); + } + + return value; +} + +export function validateChainId(chainId: string, fieldName = 'chainId'): string { + const value = assertNonEmptyString(chainId, fieldName); + if (!CHAIN_ID_PATTERN.test(value)) { + throw new Error(`${fieldName} format is invalid`); + } + return value; +} + +export function validateNodeId(nodeId: string, fieldName = 'nodeId'): string { + const value = assertNonEmptyString(nodeId, fieldName); + if (!NODE_ID_PATTERN.test(value)) { + throw new Error(`${fieldName} format is invalid`); + } + return value; +} + +export function validateContractAddress(contractAddress: string, fieldName = 'contractAddress'): string { + const value = assertNonEmptyString(contractAddress, fieldName); + if (!CONTRACT_ADDRESS_PATTERN.test(value)) { + throw new Error(`${fieldName} format is invalid`); + } + return value; +} + +export function validateMethodName(methodName: string, fieldName = 'methodName'): string { + return assertNonEmptyString(methodName, fieldName); +} + +export function validateChainTargetInput(input: ChainTargetInput): void { + if (input.rpcUrl !== undefined) { + validateRpcUrl(input.rpcUrl, 'rpcUrl'); + } + if (input.chainId !== undefined) { + validateChainId(String(input.chainId), 'chainId'); + } + if (input.nodeId !== undefined) { + validateNodeId(input.nodeId, 'nodeId'); + } +} + +export function validateNodeProfileInput(input: ImportNodeInput): void { + validateNodeId(input.id, 'id'); + validateChainId(String(input.chainId), 'chainId'); + validateRpcUrl(input.rpcUrl, 'rpcUrl'); +} + +export function validateRequiredText(input: string, fieldName: string): string { + return assertNonEmptyString(input, fieldName); +} diff --git a/package.json b/package.json index bd3cce3..d6667f1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@aelfproject/aelf-node-skill", "version": "0.1.0", - "description": "AElf Node Skill for AI agents: MCP, CLI, and SDK with SDK-first + REST fallback architecture.", + "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", "exports": { diff --git a/src/core/contract.ts b/src/core/contract.ts index 74ed1f4..a7bfddf 100644 --- a/src/core/contract.ts +++ b/src/core/contract.ts @@ -1,11 +1,16 @@ import { getEoaPrivateKey } from '../../lib/config.js'; import { resolveNode } from '../../lib/node-router.js'; import { callContractView as callContractViewBySdk, sendContractTransaction as sendContractTransactionBySdk } from '../../lib/sdk-client.js'; +import { validateChainTargetInput, validateContractAddress, validateMethodName } from '../../lib/validators.js'; import { executeWithResponse } from './common.js'; import type { CallContractViewInput, SendContractTransactionInput, SkillResponse } from '../../lib/types.js'; export async function callContractView(input: CallContractViewInput): Promise> { return executeWithResponse(async () => { + validateChainTargetInput(input); + validateContractAddress(input.contractAddress); + validateMethodName(input.methodName); + const { node } = await resolveNode(input); return callContractViewBySdk(node.rpcUrl, input.contractAddress, input.methodName, input.params || {}); }, 'CALL_CONTRACT_VIEW_FAILED'); @@ -13,6 +18,10 @@ export async function callContractView(input: CallContractViewInput): Promise> { return executeWithResponse(async () => { + validateChainTargetInput(input); + validateContractAddress(input.contractAddress); + validateMethodName(input.methodName); + const { node } = await resolveNode(input); const privateKey = getEoaPrivateKey(input.privateKey); if (!privateKey) { diff --git a/src/core/node-registry.ts b/src/core/node-registry.ts index 3397454..118652d 100644 --- a/src/core/node-registry.ts +++ b/src/core/node-registry.ts @@ -1,10 +1,14 @@ import { importNode as importNodeToRegistry, listImportedNodes } from '../../lib/node-registry.js'; import { listAvailableNodes } from '../../lib/node-router.js'; +import { validateNodeProfileInput } from '../../lib/validators.js'; import { executeWithResponse } from './common.js'; import type { ImportNodeInput, SkillResponse } from '../../lib/types.js'; export async function importNode(input: ImportNodeInput): Promise> { - return executeWithResponse(async () => importNodeToRegistry(input), 'IMPORT_NODE_FAILED'); + return executeWithResponse(async () => { + validateNodeProfileInput(input); + return importNodeToRegistry(input); + }, 'IMPORT_NODE_FAILED'); } export async function listNodes(): Promise> { diff --git a/src/core/query.ts b/src/core/query.ts index 48e75ad..3b70932 100644 --- a/src/core/query.ts +++ b/src/core/query.ts @@ -1,7 +1,19 @@ -import { getEoaPrivateKey } from '../../lib/config.js'; +import { + getEoaPrivateKey, + getRestClientCacheMax, + getRetryCount, + getTimeoutMs, +} from '../../lib/config.js'; import { resolveNode } from '../../lib/node-router.js'; import { RestClient } from '../../lib/rest-client.js'; import { buildSignedTransaction, estimateTransactionFeeBySdk } from '../../lib/sdk-client.js'; +import { LruCache } from '../../lib/utils/lru.js'; +import { + validateChainTargetInput, + validateContractAddress, + validateMethodName, + validateRequiredText, +} from '../../lib/validators.js'; import { executeWithResponse } from './common.js'; import type { ChainTargetInput, @@ -13,12 +25,29 @@ import type { SkillResponse, } from '../../lib/types.js'; +const restClientCache = new LruCache(getRestClientCacheMax()); + +function getRestClientKey(rpcUrl: string, timeoutMs: number, retry: number): string { + return `${rpcUrl}|${timeoutMs}|${retry}`; +} + function clientFor(rpcUrl: string): RestClient { - return new RestClient(rpcUrl); + const timeoutMs = getTimeoutMs(); + const retry = getRetryCount(); + const key = getRestClientKey(rpcUrl, timeoutMs, retry); + const cached = restClientCache.get(key); + if (cached) { + return cached; + } + + const client = new RestClient(rpcUrl, timeoutMs, retry); + restClientCache.set(key, client); + return client; } export async function getChainStatus(input: ChainTargetInput = {}): Promise> { return executeWithResponse(async () => { + validateChainTargetInput(input); const { node } = await resolveNode(input); return clientFor(node.rpcUrl).request({ method: 'GET', path: 'blockChain/chainStatus' }); }, 'GET_CHAIN_STATUS_FAILED'); @@ -26,6 +55,7 @@ export async function getChainStatus(input: ChainTargetInput = {}): Promise> { return executeWithResponse(async () => { + validateChainTargetInput(input); const { node } = await resolveNode(input); return clientFor(node.rpcUrl).request({ method: 'GET', path: 'blockChain/blockHeight' }); }, 'GET_BLOCK_HEIGHT_FAILED'); @@ -33,6 +63,8 @@ export async function getBlockHeight(input: ChainTargetInput = {}): Promise> { return executeWithResponse(async () => { + validateChainTargetInput(input); + validateRequiredText(input.blockHash, 'blockHash'); const { node } = await resolveNode(input); return clientFor(node.rpcUrl).request({ method: 'GET', @@ -47,6 +79,8 @@ export async function getBlock(input: GetBlockInput): Promise> { return executeWithResponse(async () => { + validateChainTargetInput(input); + validateRequiredText(input.transactionId, 'transactionId'); const { node } = await resolveNode(input); return clientFor(node.rpcUrl).request({ method: 'GET', @@ -60,6 +94,8 @@ export async function getTransactionResult(input: GetTransactionResultInput): Pr export async function getContractViewMethods(input: GetContractViewMethodsInput): Promise> { return executeWithResponse(async () => { + validateChainTargetInput(input); + validateContractAddress(input.contractAddress); const { node } = await resolveNode(input); return clientFor(node.rpcUrl).request({ method: 'GET', @@ -73,6 +109,8 @@ export async function getContractViewMethods(input: GetContractViewMethodsInput) export async function getSystemContractAddress(input: GetSystemContractAddressInput): Promise> { return executeWithResponse(async () => { + validateChainTargetInput(input); + validateRequiredText(input.contractName, 'contractName'); const { node } = await resolveNode(input); return clientFor(node.rpcUrl).request({ method: 'GET', @@ -93,6 +131,9 @@ async function resolveRawTransaction(input: EstimateTransactionFeeInput, rpcUrl: throw new Error('Either rawTransaction or contractAddress + methodName is required'); } + 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'); @@ -109,6 +150,7 @@ async function resolveRawTransaction(input: EstimateTransactionFeeInput, rpcUrl: export async function estimateTransactionFee(input: EstimateTransactionFeeInput): Promise> { return executeWithResponse(async () => { + validateChainTargetInput(input); const { node } = await resolveNode(input); const rawTransaction = await resolveRawTransaction(input, node.rpcUrl); const client = clientFor(node.rpcUrl); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index da571c8..61172c9 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; +import packageJson from '../../package.json'; import { callContractView, estimateTransactionFee, @@ -18,7 +19,7 @@ import { const server = new McpServer({ name: 'aelf-node-skill', - version: '0.1.0', + version: packageJson.version, }); function asMcpResult(data: unknown) { @@ -35,7 +36,7 @@ function asMcpResult(data: unknown) { const chainTargetSchema = { chainId: z.string().optional().describe('Chain id, e.g. AELF or tDVV'), nodeId: z.string().optional().describe('Optional imported node id'), - rpcUrl: z.string().optional().describe('Direct rpc url override'), + rpcUrl: z.string().optional().describe('Direct rpc url override, only http/https is accepted'), }; server.registerTool( @@ -122,13 +123,12 @@ server.registerTool( server.registerTool( 'aelf_send_contract_transaction', { - description: 'Send contract transaction via aelf-sdk with EOA signer and optional tx polling.', + description: 'Send contract transaction via aelf-sdk with env-based signer and optional tx polling.', inputSchema: { ...chainTargetSchema, contractAddress: z.string().describe('Contract address'), methodName: z.string().describe('Method name'), params: z.record(z.unknown()).optional(), - privateKey: z.string().optional().describe('Private key override. Defaults to AELF_PRIVATE_KEY env.'), waitForMined: z.boolean().optional().default(true), maxRetries: z.number().int().optional().default(20), retryIntervalMs: z.number().int().optional().default(1500), @@ -147,7 +147,6 @@ server.registerTool( contractAddress: z.string().optional(), methodName: z.string().optional(), params: z.record(z.unknown()).optional(), - privateKey: z.string().optional(), }, }, async input => asMcpResult(await estimateTransactionFee(input)), diff --git a/tests/unit/contract-core.test.ts b/tests/unit/contract-core.test.ts new file mode 100644 index 0000000..8a942b0 --- /dev/null +++ b/tests/unit/contract-core.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; + +type ContractMockState = { + callContractViewCalls: Array<{ + rpcUrl: string; + contractAddress: string; + methodName: string; + params: Record; + }>; + sendContractTransactionCalls: Array<{ + rpcUrl: string; + contractAddress: string; + methodName: string; + params: Record; + privateKey: string; + waitForMined: boolean; + maxRetries: number; + retryIntervalMs: number; + }>; + callContractViewImpl: ( + rpcUrl: string, + contractAddress: string, + methodName: string, + params: Record, + ) => Promise; + sendContractTransactionImpl: ( + rpcUrl: string, + contractAddress: string, + methodName: string, + params: Record, + privateKey: string, + waitForMined: boolean, + maxRetries: number, + retryIntervalMs: number, + ) => Promise; +}; + +function defaultState(): ContractMockState { + return { + callContractViewCalls: [], + sendContractTransactionCalls: [], + callContractViewImpl: async () => ({ balance: '100' }), + sendContractTransactionImpl: async () => ({ transactionId: '0xtx' }), + }; +} + +const g = globalThis as Record; +const state = (g.__AELF_CONTRACT_CORE_MOCK_STATE as ContractMockState | undefined) || defaultState(); +g.__AELF_CONTRACT_CORE_MOCK_STATE = state; + +function resetState(): void { + const next = defaultState(); + state.callContractViewCalls = next.callContractViewCalls; + state.sendContractTransactionCalls = next.sendContractTransactionCalls; + state.callContractViewImpl = next.callContractViewImpl; + state.sendContractTransactionImpl = next.sendContractTransactionImpl; +} + +mock.module('../../lib/sdk-client.js', () => ({ + callContractView: async ( + rpcUrl: string, + contractAddress: string, + methodName: string, + params: Record, + ) => { + state.callContractViewCalls.push({ rpcUrl, contractAddress, methodName, params }); + return state.callContractViewImpl(rpcUrl, contractAddress, methodName, params); + }, + sendContractTransaction: async ( + rpcUrl: string, + contractAddress: string, + methodName: string, + params: Record, + privateKey: string, + waitForMined: boolean, + maxRetries: number, + retryIntervalMs: number, + ) => { + state.sendContractTransactionCalls.push({ + rpcUrl, + contractAddress, + methodName, + params, + privateKey, + waitForMined, + maxRetries, + retryIntervalMs, + }); + return state.sendContractTransactionImpl( + rpcUrl, + contractAddress, + methodName, + params, + privateKey, + waitForMined, + maxRetries, + retryIntervalMs, + ); + }, +})); + +let contractCore: typeof import('../../src/core/contract.js'); +const originalPrivateKey = process.env.AELF_PRIVATE_KEY; +const validContractAddress = '7RzVGiuVWkvL4VfVHdZfQF2Tri3sgLe9U991bohHFfSRZXuGX'; + +beforeAll(async () => { + contractCore = await import('../../src/core/contract.js'); +}); + +beforeEach(() => { + resetState(); +}); + +afterEach(() => { + if (originalPrivateKey === undefined) { + delete process.env.AELF_PRIVATE_KEY; + } else { + process.env.AELF_PRIVATE_KEY = originalPrivateKey; + } +}); + +describe('core/contract', () => { + it('calls sdk view method successfully', async () => { + const result = await contractCore.callContractView({ + rpcUrl: 'https://mock-node.test', + contractAddress: validContractAddress, + methodName: 'GetBalance', + params: { symbol: 'ELF' }, + }); + + expect(result.ok).toBe(true); + expect(state.callContractViewCalls.length).toBe(1); + expect(state.callContractViewCalls[0]?.contractAddress).toBe(validContractAddress); + }); + + it('rejects invalid contract address before sdk call', async () => { + const result = await contractCore.callContractView({ + rpcUrl: 'https://mock-node.test', + contractAddress: 'invalid-address', + methodName: 'GetBalance', + }); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('CALL_CONTRACT_VIEW_FAILED'); + expect(state.callContractViewCalls.length).toBe(0); + }); + + it('fails send call when private key is missing', async () => { + delete process.env.AELF_PRIVATE_KEY; + + const result = await contractCore.sendContractTransaction({ + rpcUrl: 'https://mock-node.test', + contractAddress: validContractAddress, + methodName: 'Transfer', + params: { + to: validContractAddress, + amount: '1', + }, + }); + + expect(result.ok).toBe(false); + expect(result.error?.message.includes('AELF_PRIVATE_KEY is required for write operations')).toBe(true); + expect(state.sendContractTransactionCalls.length).toBe(0); + }); + + it('uses env private key for send call', async () => { + process.env.AELF_PRIVATE_KEY = 'test-private-key'; + + const result = await contractCore.sendContractTransaction({ + rpcUrl: 'https://mock-node.test', + contractAddress: validContractAddress, + methodName: 'Transfer', + params: { + to: validContractAddress, + amount: '1', + }, + waitForMined: false, + }); + + expect(result.ok).toBe(true); + expect(state.sendContractTransactionCalls.length).toBe(1); + expect(state.sendContractTransactionCalls[0]?.privateKey).toBe('test-private-key'); + }); +}); diff --git a/tests/unit/contract-path-compat.test.ts b/tests/unit/contract-path-compat.test.ts index de19eae..b43f9d8 100644 --- a/tests/unit/contract-path-compat.test.ts +++ b/tests/unit/contract-path-compat.test.ts @@ -9,6 +9,7 @@ afterEach(() => { describe('contract view method list path compatibility', () => { it('must call /api/contract/contractViewMethodList instead of legacy blockChain path', async () => { + const contractAddress = '7RzVGiuVWkvL4VfVHdZfQF2Tri3sgLe9U991bohHFfSRZXuGX'; let requestedUrl = ''; globalThis.fetch = (async (input: RequestInfo | URL) => { requestedUrl = String(input); @@ -22,7 +23,7 @@ describe('contract view method list path compatibility', () => { const result = await getContractViewMethods({ rpcUrl: 'https://mock-node.test', - contractAddress: 'mock-address', + contractAddress, }); expect(result.ok).toBe(true); diff --git a/tests/unit/mcp-server-schema.test.ts b/tests/unit/mcp-server-schema.test.ts new file mode 100644 index 0000000..2d837b0 --- /dev/null +++ b/tests/unit/mcp-server-schema.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'bun:test'; +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', () => { + 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('version: packageJson.version')).toBe(true); + }); +}); diff --git a/tests/unit/node-registry-core.test.ts b/tests/unit/node-registry-core.test.ts new file mode 100644 index 0000000..ea0cd1b --- /dev/null +++ b/tests/unit/node-registry-core.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { importNode, listNodes } from '../../src/core/node-registry.js'; + +const originalRegistryPath = process.env.AELF_NODE_REGISTRY_PATH; +let currentTempDir = ''; + +beforeEach(async () => { + currentTempDir = await mkdtemp(join(tmpdir(), 'aelf-node-registry-core-test-')); + process.env.AELF_NODE_REGISTRY_PATH = join(currentTempDir, 'nodes.json'); +}); + +afterEach(async () => { + if (originalRegistryPath === undefined) { + delete process.env.AELF_NODE_REGISTRY_PATH; + } else { + process.env.AELF_NODE_REGISTRY_PATH = originalRegistryPath; + } + + if (currentTempDir) { + await rm(currentTempDir, { recursive: true, force: true }); + currentTempDir = ''; + } +}); + +describe('core/node-registry', () => { + it('validates node input before writing', async () => { + const result = await importNode({ + id: 'custom-aelf', + chainId: 'AELF', + rpcUrl: 'file:///tmp/not-allowed', + enabled: true, + }); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('IMPORT_NODE_FAILED'); + expect(result.error?.message.includes('rpcUrl must use http or https protocol')).toBe(true); + }); + + it('does not lose writes when importNode runs concurrently', async () => { + const [first, second] = await Promise.all([ + importNode({ + id: 'custom-aelf', + chainId: 'AELF', + rpcUrl: 'https://custom-aelf-node.test', + enabled: true, + }), + importNode({ + id: 'custom-tdvv', + chainId: 'tDVV', + rpcUrl: 'https://custom-tdvv-node.test', + enabled: true, + }), + ]); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + + const listed = await listNodes(); + expect(listed.ok).toBe(true); + + const imported = (listed.data as any).imported as Array<{ id: string }>; + const importedIds = imported.map(item => item.id); + + expect(importedIds.includes('custom-aelf')).toBe(true); + expect(importedIds.includes('custom-tdvv')).toBe(true); + }); +}); diff --git a/tests/unit/query-core.test.ts b/tests/unit/query-core.test.ts index c55d414..a972bf0 100644 --- a/tests/unit/query-core.test.ts +++ b/tests/unit/query-core.test.ts @@ -11,6 +11,7 @@ import { const originalFetch = globalThis.fetch; const originalPrivateKey = process.env.AELF_PRIVATE_KEY; +const validContractAddress = '7RzVGiuVWkvL4VfVHdZfQF2Tri3sgLe9U991bohHFfSRZXuGX'; function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { @@ -49,7 +50,7 @@ describe('query core flows', () => { return jsonResponse({ Status: 'MINED' }); } if (url.includes('/api/contract/systemContractAddressByName?')) { - return jsonResponse('2dnGfQx...'); + return jsonResponse(validContractAddress); } if (url.includes('/api/contract/contractViewMethodList?')) { return jsonResponse(['GetBalance']); @@ -68,7 +69,7 @@ describe('query core flows', () => { }); const methods = await getContractViewMethods({ rpcUrl: 'https://mock-node.test', - contractAddress: '2dnGfQx...', + contractAddress: validContractAddress, }); expect(chain.ok).toBe(true); @@ -120,7 +121,7 @@ describe('query core flows', () => { const result = await estimateTransactionFee({ rpcUrl: 'https://mock-node.test', - contractAddress: '2dnGfQx...', + contractAddress: validContractAddress, methodName: 'Transfer', params: { to: 'address', diff --git a/tests/unit/rest-client.test.ts b/tests/unit/rest-client.test.ts new file mode 100644 index 0000000..fc5d7de --- /dev/null +++ b/tests/unit/rest-client.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { RestClient } from '../../lib/rest-client.js'; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('lib/rest-client', () => { + it('keeps plain numeric text as string', async () => { + globalThis.fetch = (async () => new Response('12345678901234567890', { status: 200 })) as typeof fetch; + + const client = new RestClient('https://mock-node.test', 100, 0); + const result = await client.request({ method: 'GET', path: 'blockChain/blockHeight' }); + + expect(typeof result).toBe('string'); + expect(result).toBe('12345678901234567890'); + }); + + it('retries failed request before succeeding', async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + if (calls === 1) { + return new Response(JSON.stringify({ Error: { Code: 'TEMP', Message: 'temporary' } }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) 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' }); + + expect(calls).toBe(2); + expect(result.ok).toBe(true); + }); + + it('aborts request on timeout', async () => { + globalThis.fetch = ((_: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + signal?.addEventListener('abort', () => { + reject(new Error('aborted')); + }); + })) 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/sdk-client.test.ts b/tests/unit/sdk-client.test.ts new file mode 100644 index 0000000..57cc171 --- /dev/null +++ b/tests/unit/sdk-client.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { + clearSdkCacheForRpc, + clearSdkCaches, + getAElfInstance, + getReadOnlyWallet, +} from '../../lib/sdk-client.js'; + +afterEach(() => { + clearSdkCaches(); +}); + +describe('lib/sdk-client cache behavior', () => { + it('reuses readonly wallet until caches are cleared', () => { + const first = getReadOnlyWallet(); + const second = getReadOnlyWallet(); + + expect(first).toBe(second); + + clearSdkCaches(); + + const third = getReadOnlyWallet(); + expect(third).not.toBe(first); + }); + + it('reuses aelf instance for same rpc and clears by rpc key', () => { + const rpcUrl = 'https://mock-node.test'; + + const first = getAElfInstance(rpcUrl); + const second = getAElfInstance(rpcUrl); + expect(first).toBe(second); + + clearSdkCacheForRpc(rpcUrl); + + const third = getAElfInstance(rpcUrl); + expect(third).not.toBe(first); + }); +}); diff --git a/tests/unit/signer.test.ts b/tests/unit/signer.test.ts new file mode 100644 index 0000000..92fc7aa --- /dev/null +++ b/tests/unit/signer.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'bun:test'; +import AElf from 'aelf-sdk'; +import { EoaSigner } from '../../lib/signer.js'; + +describe('lib/signer', () => { + it('creates signer from private key and exposes wallet address', () => { + const privateKey = 'f6e512a3c259e5f9af981d7f99d245aa5bc52fe448495e0b0dd56e8406be6f71'; + + const signer = new EoaSigner(privateKey); + const expectedWallet = AElf.wallet.getWalletByPrivateKey(privateKey); + + expect(signer.getAddress()).toBe(expectedWallet.address); + expect(signer.getWallet().address).toBe(expectedWallet.address); + }); +}); diff --git a/tests/unit/validators.test.ts b/tests/unit/validators.test.ts new file mode 100644 index 0000000..f620513 --- /dev/null +++ b/tests/unit/validators.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'bun:test'; +import { + validateChainId, + validateContractAddress, + validateNodeId, + validateRpcUrl, +} from '../../lib/validators.js'; + +describe('lib/validators', () => { + it('accepts http and https rpc urls', () => { + expect(validateRpcUrl('https://aelf-public-node.aelf.io')).toBe('https://aelf-public-node.aelf.io'); + expect(validateRpcUrl('http://127.0.0.1:8000')).toBe('http://127.0.0.1:8000'); + }); + + it('rejects non-http protocols', () => { + expect(() => validateRpcUrl('file:///tmp/a')).toThrow('rpcUrl must use http or https protocol'); + }); + + it('validates chainId, nodeId and contractAddress formats', () => { + expect(validateChainId('AELF')).toBe('AELF'); + expect(validateNodeId('custom-node_1')).toBe('custom-node_1'); + expect(validateContractAddress('7RzVGiuVWkvL4VfVHdZfQF2Tri3sgLe9U991bohHFfSRZXuGX')).toBe( + '7RzVGiuVWkvL4VfVHdZfQF2Tri3sgLe9U991bohHFfSRZXuGX', + ); + }); +}); From d253bc9a0f9dbbe8c8f40ad2dea27b2966584dcd Mon Sep 17 00:00:00 2001 From: aelf-hzz780 Date: Thu, 26 Feb 2026 21:03:49 +0800 Subject: [PATCH 2/3] =?UTF-8?q?chore(release):=20=F0=9F=8F=B7=EF=B8=8F=20s?= =?UTF-8?q?witch=20npm=20scope=20to=20@blockchain-forever?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/setup.ts | 2 +- bun.lock | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/setup.ts b/bin/setup.ts index f0cbbe6..cc8515b 100755 --- a/bin/setup.ts +++ b/bin/setup.ts @@ -19,7 +19,7 @@ const program = new Command(); program .name('aelf-node-setup') - .description('Configure @aelfproject/aelf-node-skill for Claude/Cursor/OpenClaw') + .description('Configure @blockchain-forever/aelf-node-skill for Claude/Cursor/OpenClaw') .version('0.1.0'); const withCommonMcpOptions = (command: Command) => diff --git a/bun.lock b/bun.lock index 97ca06f..e6f8a89 100644 --- a/bun.lock +++ b/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "@aelfproject/aelf-node-skill", + "name": "@blockchain-forever/aelf-node-skill", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "aelf-sdk": "3.5.1-beta.0", diff --git a/package.json b/package.json index d6667f1..82fad86 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@aelfproject/aelf-node-skill", + "name": "@blockchain-forever/aelf-node-skill", "version": "0.1.0", "description": "AElf Node Skill for AI agents: REST for reads, SDK for contract execution, and selective fallback for fee estimate.", "type": "module", From fe62e6c6b269b60591c06afbba2201c11fd79c5b Mon Sep 17 00:00:00 2001 From: aelf-hzz780 Date: Thu, 26 Feb 2026 21:09:24 +0800 Subject: [PATCH 3/3] =?UTF-8?q?ci(workflow):=20=F0=9F=94=A7=20split=20test?= =?UTF-8?q?=20and=20publish=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish.yml | 14 +------------- .github/workflows/test.yml | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 20879f8..4029eb6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,11 +1,7 @@ name: Publish to npm on: - pull_request: push: - branches: - - main - - master tags: - 'v*' @@ -25,15 +21,7 @@ jobs: - run: bun install - - run: bun run test:unit:coverage:gate - env: - CORE_COVERAGE_THRESHOLD: '80' - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - files: ./coverage/lcov.info - fail_ci_if_error: false + - run: bun run test:unit publish: if: startsWith(github.ref, 'refs/tags/v') diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c29334a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Unit Test + +on: + pull_request: + push: + branches: + - main + - master + +permissions: + contents: read + +jobs: + unit-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - run: bun install + + - run: bun run test:unit:coverage:gate + env: + CORE_COVERAGE_THRESHOLD: '80' + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: ./coverage/lcov.info + fail_ci_if_error: false