Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 1 addition & 13 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
name: Publish to npm

on:
pull_request:
push:
branches:
- main
- master
tags:
- 'v*'

Expand All @@ -25,15 +21,7 @@ jobs:

- run: bun install

- run: bun run test:unit:coverage:gate
env:
CORE_COVERAGE_THRESHOLD: '70'

- 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')
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 公共节点。

## 功能

Expand Down Expand Up @@ -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 列表

Expand Down
2 changes: 1 addition & 1 deletion bin/check-core-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
2 changes: 1 addition & 1 deletion bin/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 52 additions & 1 deletion lib/aelf-sdk.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<unknown>;
call?: (params?: Record<string, unknown>) => Promise<unknown>;
getSignedTx?: (params?: Record<string, unknown>) => string;
}

export interface AelfContract {
[methodName: string]: AelfContractMethod | unknown;
}

export interface AelfChainApi {
contractAt(contractAddress: string, wallet: AelfWallet): Promise<AelfContract>;
getTxResult(transactionId: string): Promise<AelfTxResult>;
calculateTransactionFee(rawTransaction: string): Promise<unknown>;
}

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;
}
23 changes: 21 additions & 2 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
{
Expand All @@ -22,16 +25,32 @@ 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 {
const value = Number(process.env.AELF_NODE_RETRY);
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');
}
Expand Down
70 changes: 49 additions & 21 deletions lib/node-registry.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,6 +23,31 @@ function normalizeNode(node: NodeProfile): NodeProfile {
};
}

let registryLock: Promise<void> = Promise.resolve();

async function withRegistryLock<T>(action: () => Promise<T>): Promise<T> {
const previous = registryLock;
let release: (() => void) | undefined;

registryLock = new Promise<void>(resolve => {
release = resolve;
});

await previous;

try {
return await action();
} finally {
release?.();
}
}

async function writeRegistryAtomic(path: string, content: string): Promise<void> {
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(tempPath, content, 'utf8');
await rename(tempPath, path);
}

export async function readNodeRegistry(): Promise<NodeRegistryFile> {
const path = getRegistryPath();
try {
Expand All @@ -42,7 +68,7 @@ export async function readNodeRegistry(): Promise<NodeRegistryFile> {
export async function writeNodeRegistry(file: NodeRegistryFile): Promise<void> {
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<NodeProfile[]> {
Expand All @@ -51,25 +77,27 @@ export async function listImportedNodes(): Promise<NodeProfile[]> {
}

export async function importNode(input: ImportNodeInput): Promise<NodeProfile> {
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;
});
}
3 changes: 3 additions & 0 deletions lib/node-router.ts
Original file line number Diff line number Diff line change
@@ -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[] {
Expand All @@ -14,6 +15,8 @@ export async function listAvailableNodes(): Promise<NodeProfile[]> {
}

export async function resolveNode(input: ResolveNodeInput): Promise<ResolveNodeResult> {
validateChainTargetInput(input);

if (input.rpcUrl) {
return {
node: {
Expand Down
Loading