Skip to content
Closed
57 changes: 57 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# GitHub Copilot Instructions

This file provides guidance to GitHub Copilot when working with code in this repository.
Keep this file in sync with `CLAUDE.md` β€” changes to one must be reflected in the other.

## Lenses

Apply all lenses before proposing any solution. Each lens constrains acceptable answers.

- Hummingbot lens: Gateway is consumed by Hummingbot Python strategies via typed connector classes. API response shapes are parsed directly into Python dicts β€” breaking changes to field types or names silently corrupt live trading bots. Prefer additive changes (new optional fields) over mutations. `walletAddresses` must remain `string[]`. Use Tolerant Reader pattern for all response extensions.
- Blockchain lens: The `chain` field is the technology substrate (ethereum = all EVM, solana = SVM). `network` is the L1/L2 brand discriminator (mainnet, bsc, arbitrum, base, polygon, avalanche). A wallet address is chain-scoped, not network-scoped β€” the same keypair works across all EVM networks. Wallet files are stored under `conf/wallets/<chain>/<address>.json` as `{encryptedKey, network}` JSON; legacy files contain a raw encrypted string and must be handled transparently.
- System Architect lens: Routes follow `/{resource}/{operation}` REST conventions. Schemas are TypeBox objects auto-published to Swagger β€” every new field must be typed. Backwards compatibility is enforced via optional fields, never field removal or type mutation. Singleton pattern governs chain/connector instances (`getInstance(network)`). Error responses must use Fastify `httpErrors` β€” never throw raw errors from route handlers.
- Bitcoin lens: Not directly supported, but cryptographic primitives (key derivation, encryption, signing) must remain chain-agnostic. Wallet encryption uses a passphrase-derived key stored outside source control. Never log or expose private keys or passphrases in any code path.
- Jest lens: Mock external deps (fs, RPC, chains) β€” never write real files during tests. Test both happy paths and regressions. 100% coverage on utils, 75%+ on routes. Use `jest.mock()` for file/crypto ops. Parallel tests should not share state. Validate schema contracts before business logic.
- QA lens: Validate backwards compatibility at every response boundary. Legacy wallet files must parse identically. New optional fields should not break old consumers. Test migration scenarios: old wallets β†’ new system, new fields with old clients. Regression suite covers all breaking-change-adjacent code paths.
- Security lens: Never log or expose private keys, passphrases, mnemonic seeds, or decrypted values. All file I/O must use `getSafeWalletFilePath()` with sanitized inputs. Wallet encryption keys derive from passphrase outside source control. Validate address formats to prevent injection. All secrets must be stored in `conf/` outside repo.

## Build & Command Reference

- Build: `pnpm build`
- Start server: `pnpm start --passphrase=<PASSPHRASE>`
- Start in dev mode: `pnpm start --passphrase=<PASSPHRASE> --dev` (HTTP mode, no SSL)
- Run all tests: `pnpm test`
- Run specific test file: `GATEWAY_TEST_MODE=dev jest --runInBand path/to/file.test.ts`
- Run tests with coverage: `pnpm test:cov`
- Lint: `pnpm lint` / Format: `pnpm format` / Type check: `pnpm typecheck`

## Architecture Overview

- RESTful API gateway built with Fastify + TypeBox schemas (auto-generates Swagger at `/docs`)
- Chain routes: `/chains/{chain}/{operation}` β€” e.g. `/chains/ethereum/balances`
- Connector routes: `/connectors/{dex}/{type}/{operation}` β€” type is `router`, `amm`, or `clmm`
- Wallet routes: `/wallet/*`
- Config routes: `/config/*`
- Chains are singletons: `Ethereum.getInstance(network)`, `Solana.getInstance(network)`
- Connectors are singletons: `Pancakeswap.getInstance(network)`, `Uniswap.getInstance(network)`
- `chain` = substrate (`ethereum` covers all EVM networks, `solana` covers all SVM networks)
- `network` = specific network (`mainnet`, `bsc`, `arbitrum`, `base`, `mainnet-beta`, etc.)
- `chainNetwork` = combined shorthand (`ethereum-bsc`, `ethereum-arbitrum`) parsed as `chain-network`

## Coding Style

- TypeScript, ESNext, CommonJS modules, 2-space indent, single quotes, semicolons required
- TypeBox for all request/response schemas β€” no untyped `any` in route handlers
- `logger` for all logging β€” never `console.log`
- `fastify.httpErrors.*` for all API error responses β€” never throw raw `Error` from handlers
- Unused variables prefixed with `_`
- Tests required for all new functionality (min 75% coverage for PRs)
- Test files mirror `src/` structure under `test/`; mocks live in `test/mocks/`

## Key Patterns

- New wallet files: `JSON.stringify({ encryptedKey, network })` β€” always read with fallback to legacy raw string
- `chainNetwork` parsing: `parts = val.split('-'); chain = parts[0]; network = parts.slice(1).join('-')`
- Response extension: add optional fields alongside existing ones β€” never mutate existing field types
- Route files live in `{module}/routes/{operation}.ts`, registered in `{module}.routes.ts`
- Pool configs: `src/templates/pools/{connector}.json` β€” format: `{ type, network, baseSymbol, quoteSymbol, baseTokenAddress, quoteTokenAddress, feePct, address }`
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
# AI Agent Instructions

This file provides guidance to AI coding assistants when working with code in this repository.
Keep this file in sync with `.github/copilot-instructions.md` β€” changes to one must be reflected in the other.

## Lenses

Apply all lenses before proposing any solution. Each lens constrains acceptable answers.

- Hummingbot lens: Gateway is consumed by Hummingbot Python strategies via typed connector classes. API response shapes are parsed directly into Python dicts β€” breaking changes to field types or names silently corrupt live trading bots. Prefer additive changes (new optional fields) over mutations. `walletAddresses` must remain `string[]`. Use Tolerant Reader pattern for all response extensions.
- Blockchain lens: The `chain` field is the technology substrate (ethereum = all EVM, solana = SVM). `network` is the L1/L2 brand discriminator (mainnet, bsc, arbitrum, base, polygon, avalanche). A wallet address is chain-scoped, not network-scoped β€” the same keypair works across all EVM networks. Wallet files are stored under `conf/wallets/<chain>/<address>.json` as `{encryptedKey, network}` JSON; legacy files contain a raw encrypted string and must be handled transparently.
- System Architect lens: Routes follow `/{resource}/{operation}` REST conventions. Schemas are TypeBox objects auto-published to Swagger β€” every new field must be typed. Backwards compatibility is enforced via optional fields, never field removal or type mutation. Singleton pattern governs chain/connector instances (`getInstance(network)`). Error responses must use Fastify `httpErrors` β€” never throw raw errors from route handlers.
- Bitcoin lens: Not directly supported, but cryptographic primitives (key derivation, encryption, signing) must remain chain-agnostic. Wallet encryption uses a passphrase-derived key stored outside source control. Never log or expose private keys or passphrases in any code path.
- Jest lens: Mock external deps (fs, RPC, chains) β€” never write real files during tests. Test both happy paths and regressions. 100% coverage on utils, 75%+ on routes. Use `jest.mock()` for file/crypto ops. Parallel tests should not share state. Validate schema contracts before business logic.
- QA lens: Validate backwards compatibility at every response boundary. Legacy wallet files must parse identically. New optional fields should not break old consumers. Test migration scenarios: old wallets β†’ new system, new fields with old clients. Regression suite covers all breaking-change-adjacent code paths.
- Security lens: Never log or expose private keys, passphrases, mnemonic seeds, or decrypted values. All file I/O must use `getSafeWalletFilePath()` with sanitized inputs. Wallet encryption keys derive from passphrase outside source control. Validate address formats to prevent injection. All secrets must be stored in `conf/` outside repo.

## Build & Command Reference

- Build: `pnpm build`
- Start server: `pnpm start --passphrase=<PASSPHRASE>`
- Start in dev mode: `pnpm start --passphrase=<PASSPHRASE> --dev` (HTTP mode, no SSL)
Expand All @@ -19,6 +33,7 @@ This file provides guidance to AI coding assistants when working with code in th
## Architecture Overview

### Gateway Pattern

- RESTful API gateway providing standardized endpoints for blockchain and DEX interactions
- Built with Fastify framework using TypeBox for schema validation
- Supports both HTTP (dev mode) and HTTPS (production) protocols
Expand Down
1 change: 1 addition & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ const configureGatewayServer = () => {
});
app.register(pancakeswapRoutes.amm, { prefix: '/connectors/pancakeswap/amm' });
app.register(pancakeswapRoutes.clmm, { prefix: '/connectors/pancakeswap/clmm' });
app.register(pancakeswapRoutes.nftStaking, { prefix: '/connectors/pancakeswap/nft-staking' });

// PancakeSwap Solana routes
app.register(pancakeswapSolRoutes, { prefix: '/connectors/pancakeswap-sol' });
Expand Down
47 changes: 30 additions & 17 deletions src/chains/ethereum/ethereum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,20 @@ export class Ethereum {
const validatedAddress = Ethereum.validateAddress(address);

const path = `${walletPath}/ethereum`;
const encryptedPrivateKey = await fse.readFile(`${path}/${validatedAddress}.json`, 'utf8');
const fileContent = await fse.readFile(`${path}/${validatedAddress}.json`, 'utf8');

// Support both new JSON format {encryptedKey, network} and legacy raw string
let encryptedPrivateKey = fileContent;
let network = this.network;
try {
const parsed = JSON.parse(fileContent);
if (parsed && typeof parsed.encryptedKey === 'string') {
encryptedPrivateKey = parsed.encryptedKey;
network = parsed.network || this.network;
}
} catch {
// Legacy format: raw encrypted string
}

const walletKey = ConfigManagerCertPassphrase.readWalletKey();
if (!walletKey) {
Expand Down Expand Up @@ -780,6 +793,17 @@ export class Ethereum {
}
}

/**
* Evict a cached instance so the next call to getInstance() re-creates it.
* Use this after changing nodeURL in config so the new provider is picked up.
*/
public static resetInstance(network: string): void {
if (Ethereum._instances && network in Ethereum._instances) {
delete Ethereum._instances[network];
logger.info(`Ethereum instance for '${network}' evicted β€” will re-initialize on next request`);
}
}

// WETH ABI for wrap/unwrap operations
private static WETH9ABI = [
// Standard ERC20 functions
Expand Down Expand Up @@ -1053,26 +1077,15 @@ export class Ethereum {
// Treat empty array as if no tokens were specified
const effectiveTokens = tokens && tokens.length === 0 ? undefined : tokens;

// Check if this is a hardware wallet
const isHardware = await this.isHardwareWallet(address);
let wallet: Wallet | null = null;

if (!isHardware) {
wallet = await this.getWallet(address);
}

// Always get native token balance
const nativeBalance = isHardware
? await this.getNativeBalanceByAddress(address)
: await this.getNativeBalance(wallet!);
// Balance queries are read-only β€” no private key needed, use address directly.
// This keeps the Security lens: never decrypt keys for operations that don't sign.
const nativeBalance = await this.getNativeBalanceByAddress(address);
balances[this.nativeTokenSymbol] = parseFloat(tokenValueToString(nativeBalance));

if (!effectiveTokens) {
// No tokens specified, check all tokens in token list
await this.getAllTokenBalances(address, wallet, isHardware, balances);
await this.getAllTokenBalances(address, null, true, balances);
} else {
// Get specific token balances
await this.getSpecificTokenBalances(effectiveTokens, address, wallet, isHardware, balances);
await this.getSpecificTokenBalances(effectiveTokens, address, null, true, balances);
}

return balances;
Expand Down
24 changes: 23 additions & 1 deletion src/chains/solana/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,18 @@ export class Solana {
const safeWalletPath = getSafeWalletFilePath('solana', validatedAddress);

// Read the wallet file using the safe path
const encryptedPrivateKey: string = await fse.readFile(safeWalletPath, 'utf8');
const fileContent: string = await fse.readFile(safeWalletPath, 'utf8');

// Support both new JSON format {encryptedKey, network} and legacy raw string
let encryptedPrivateKey = fileContent;
try {
const parsed = JSON.parse(fileContent);
if (parsed && typeof parsed.encryptedKey === 'string') {
encryptedPrivateKey = parsed.encryptedKey;
}
} catch {
// Legacy format: raw encrypted string
}

const walletKey = ConfigManagerCertPassphrase.readWalletKey();
if (!walletKey) {
Expand Down Expand Up @@ -1099,6 +1110,17 @@ export class Solana {
}
}

/**
* Evict a cached instance so the next call to getInstance() re-creates it.
* Use this after changing nodeURL in config so the new provider is picked up.
*/
public static resetInstance(network: string): void {
if (Solana._instances && network in Solana._instances) {
delete Solana._instances[network];
logger.info(`Solana instance for '${network}' evicted β€” will re-initialize on next request`);
}
}

public async estimateGas(computeUnits?: number): Promise<number> {
const computeUnitsToUse = computeUnits || this.config.defaultComputeUnits;
const priorityFeePerCU = await this.estimateGasPrice();
Expand Down
16 changes: 16 additions & 0 deletions src/config/routes/updateConfig.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { FastifyPluginAsync } from 'fastify';

import { Ethereum } from '../../chains/ethereum/ethereum';
import { Solana } from '../../chains/solana/solana';
import { ConfigManagerV2 } from '../../services/config-manager-v2';
import { logger } from '../../services/logger';
import {
Expand Down Expand Up @@ -82,6 +84,20 @@ export const updateConfigRoute: FastifyPluginAsync = async (fastify) => {

updateConfig(fastify, fullPath, processedValue);

// If nodeURL changed, evict the cached chain instance so the next request
// re-creates it with the new provider (Blockchain lens: network RPC is runtime config).
if (path === 'nodeURL') {
// namespace is e.g. "ethereum-bsc" or "solana-mainnet-beta"
const nsParts = namespace.split('-');
const chain = nsParts[0];
const network = nsParts.slice(1).join('-');
if (chain === 'ethereum' && network) {
Ethereum.resetInstance(network);
} else if (chain === 'solana' && network) {
Solana.resetInstance(network);
}
}

// Build descriptive message
const description = `'${namespace}.${path}'`;

Expand Down
Loading