deploy: POST /api/wallets + full swagger
This commit is contained in:
28
.env.example
28
.env.example
@@ -7,15 +7,39 @@ VAULT_SECRET_PATH=database
|
||||
VAULT_JWT_KID_PATH=jwt/kid
|
||||
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
||||
|
||||
# CSRF загружается если указан путь (оставь пустым чтобы отключить)
|
||||
# CSRF загружается если указан путь (оставь пустым чтобы отключить CSRF)
|
||||
VAULT_CSRF_PATH=
|
||||
|
||||
# ── JWT ────────────────────────────────────────────────────────────
|
||||
# Allowed: RS256 / RS384 / RS512 / ES256 / ES384 / ES512 / EdDSA / PS256 / PS384 / PS512
|
||||
JWT_ALGORITHM=RS256
|
||||
JWT_ISSUER=auth-service
|
||||
JWT_AUDIENCE=elcsa
|
||||
|
||||
# ── Server ─────────────────────────────────────────────────────────
|
||||
API_PORT=3001
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# ── CORS ────────────────────────────────────────────────────────────
|
||||
# Comma-separated list of allowed origins. ПУСТО = no cross-origin.
|
||||
# Никогда не используй wildcard *
|
||||
CORS_ORIGINS=
|
||||
CORS_ALLOW_CREDENTIALS=true
|
||||
|
||||
# ── External API keys (optional, fallback if Vault doesn't provide) ─
|
||||
RELAY_API_KEY=
|
||||
TRON_API_KEY=
|
||||
JUPITER_API_KEY=
|
||||
JUPITER_REFERRAL_ACCOUNT=
|
||||
JUPITER_FEE_BPS=70
|
||||
|
||||
# ── Block explorers (optional, для tx history) ─────────────────────
|
||||
ETHERSCAN_API_KEY=
|
||||
BSCSCAN_API_KEY=
|
||||
|
||||
# ── DB fallback (если Vault недоступен при старте) ─────────────────
|
||||
DB_HOST=
|
||||
DB_PORT=5432
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_NAME=
|
||||
|
||||
83
Dockerfile
83
Dockerfile
@@ -1,83 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Production Dockerfile for CryptoWallet API
|
||||
# Multi-stage: deps → build → prod-deps → runtime
|
||||
# Runtime: non-root user, tini (signal handling), wget (healthcheck)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ARG NODE_VERSION=20.19-alpine
|
||||
ARG PNPM_VERSION=10.28.2
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Base: node + pnpm
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM node:${NODE_VERSION} AS base
|
||||
ARG PNPM_VERSION
|
||||
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
CI=true \
|
||||
HUSKY=0
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# deps: install ALL deps (incl. dev) for TypeScript build
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM base AS deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build: compile TypeScript → dist/
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM base AS build
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api ./apps/api
|
||||
RUN cd apps/api && pnpm build
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# prod-deps: only production dependencies (smaller image)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM base AS prod-deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --prod
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# runtime: minimal production image
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM node:${NODE_VERSION} AS runtime
|
||||
|
||||
# tini for proper PID 1 / signal handling
|
||||
# wget for healthcheck
|
||||
RUN apk add --no-cache tini wget && \
|
||||
addgroup -S -g 1001 app && \
|
||||
adduser -S -u 1001 -G app app
|
||||
|
||||
WORKDIR /app/apps/api
|
||||
|
||||
# Copy production dependencies
|
||||
COPY --from=prod-deps --chown=app:app /app/node_modules /app/node_modules
|
||||
COPY --from=prod-deps --chown=app:app /app/apps/api/node_modules ./node_modules
|
||||
|
||||
# Copy compiled code + static assets
|
||||
COPY --from=build --chown=app:app /app/apps/api/dist ./dist
|
||||
COPY --from=build --chown=app:app /app/apps/api/swagger.json ./swagger.json
|
||||
COPY --from=build --chown=app:app /app/apps/api/package.json ./package.json
|
||||
|
||||
USER app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
API_PORT=3001
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3001/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
CMD ["node", "dist/index.js"]
|
||||
82
README.md
82
README.md
@@ -1,82 +0,0 @@
|
||||
# CryptoWallet API — Production Deploy Bundle
|
||||
|
||||
Самодостаточная папка для деплоя на Linux-сервер. Содержит всё нужное для сборки и запуска продакшн-версии API.
|
||||
|
||||
## Состав
|
||||
|
||||
```
|
||||
deployserver/
|
||||
├── Dockerfile # Multi-stage production build
|
||||
├── docker-compose.yml # Только API (БД внешняя, из Vault)
|
||||
├── .env.example # Шаблон переменных окружения
|
||||
├── .dockerignore
|
||||
├── start.sh # Автоматический deploy скрипт
|
||||
├── apps/api/ # Исходник API
|
||||
│ ├── src/
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ ├── swagger.json
|
||||
│ └── .eslintrc.json
|
||||
├── package.json # Монорепо root
|
||||
├── pnpm-workspace.yaml
|
||||
└── pnpm-lock.yaml
|
||||
```
|
||||
|
||||
## Требования
|
||||
|
||||
- Ubuntu 20.04+ / Debian 11+ / любой Linux с Docker 24+
|
||||
- Docker Compose plugin (`docker compose` команда)
|
||||
- Исходящий HTTPS на Vault (`corp.vault.elcsa.ru:443`)
|
||||
- Сетевой доступ к PostgreSQL (адрес приходит из Vault)
|
||||
- БД должна быть **инициализирована отдельно** (таблицы `users`, `wallets`, `sessions` — создаются вручную DBA)
|
||||
|
||||
## Порты
|
||||
|
||||
| Порт | Назначение | Открыть наружу? |
|
||||
|------|-----------|-----------------|
|
||||
| 3001 | API HTTP | ✅ да (`ufw allow 3001`) |
|
||||
| 443 (out) | Vault | исходящий, обычно открыт |
|
||||
| 5432 (out) | PostgreSQL | исходящий к внешнему адресу БД |
|
||||
|
||||
## Управление
|
||||
|
||||
```bash
|
||||
docker compose logs -f api # смотреть логи
|
||||
docker compose restart api # рестарт (после смены .env)
|
||||
docker compose down # остановить
|
||||
docker compose ps # статус
|
||||
docker compose up -d --build # пересобрать и запустить
|
||||
```
|
||||
|
||||
## Ротация ключей
|
||||
|
||||
JWT public keys и CSRF secret читаются из Vault при старте и **каждый час** обновляются автоматически (см. `key-rotation.service.ts`). При ошибках Vault сервис продолжает работать со старыми ключами — в логах будет `ERROR: Failed to refresh ...`.
|
||||
|
||||
## Безопасность Dockerfile
|
||||
|
||||
- **Non-root user** (uid 1001) — контейнер не работает от root
|
||||
- **tini** как PID 1 — корректная обработка `SIGTERM` / `SIGKILL`
|
||||
- **Multi-stage build** — в финальный образ попадают только production deps + компилированный dist
|
||||
- **Alpine base** — минимальный образ (~150 MB)
|
||||
- **Healthcheck** — Docker рестартит контейнер если API упал
|
||||
- **Log rotation** — max 5×20MB логов, не забьёт диск
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`Vault AppRole login failed`**
|
||||
- Проверь VAULT_ROLE_ID / VAULT_SECRET_ID в .env
|
||||
- Проверь доступ: `curl -v https://corp.vault.elcsa.ru/v1/sys/health`
|
||||
|
||||
**`relation "users" does not exist`**
|
||||
- БД не инициализирована — попроси DBA создать таблицы (`users`, `wallets`, `sessions`)
|
||||
|
||||
**Port 3001 занят**
|
||||
- `sudo lsof -i :3001`
|
||||
- Измени порт: в `docker-compose.yml` `"3002:3001"` и в `ufw allow 3002`
|
||||
|
||||
## Автозапуск при reboot
|
||||
|
||||
Restart policy `unless-stopped` уже настроен. Убедись что Docker стартует:
|
||||
```bash
|
||||
sudo systemctl enable docker
|
||||
```
|
||||
@@ -10,11 +10,14 @@
|
||||
"lint": "eslint src/ --ext .ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solana/web3.js": "^1.98.4",
|
||||
"bs58": "^6.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.0",
|
||||
"ethers": "5.7.2",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"helmet": "^8.0.0",
|
||||
"jose": "^6.2.2",
|
||||
"knex": "^3.1.0",
|
||||
|
||||
@@ -8,8 +8,10 @@ import { swaggerSpec } from './config/swagger';
|
||||
import { traceMiddleware } from './middleware/trace';
|
||||
import { authMiddleware } from './middleware/auth';
|
||||
import { csrfMiddleware } from './middleware/csrf';
|
||||
import { globalLimiter, mutateLimiter, sensitiveLimiter } from './middleware/rate-limit';
|
||||
import { errorHandler } from './middleware/error-handler';
|
||||
import walletRoutes from './routes/wallet.routes';
|
||||
import vaultRoutes from './routes/vault.routes';
|
||||
import relayProxyRoutes from './routes/relay-proxy.routes';
|
||||
import tronProxyRoutes from './routes/tron-proxy.routes';
|
||||
import solSwapProxyRoutes from './routes/sol-swap-proxy.routes';
|
||||
@@ -19,9 +21,17 @@ import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Trust proxy для корректного req.ip за reverse proxy / load balancer
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.use(helmet());
|
||||
app.use(cors({ origin: env.cors.origins, credentials: env.cors.allowCredentials }));
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
cors({
|
||||
origin: env.cors.origins.length > 0 ? env.cors.origins : false,
|
||||
credentials: env.cors.allowCredentials,
|
||||
}),
|
||||
);
|
||||
app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS
|
||||
app.use(cookieParser());
|
||||
app.use(traceMiddleware);
|
||||
|
||||
@@ -35,16 +45,24 @@ app.get('/api/docs/swagger.json', (_req, res) => {
|
||||
res.json(swaggerSpec);
|
||||
});
|
||||
|
||||
// ── PROTECTED endpoints (JWT + CSRF for mutating methods) ────────────────────
|
||||
// ── Глобальный rate limit на весь API после public endpoints ────────────────
|
||||
app.use('/api', globalLimiter);
|
||||
|
||||
// ── PROTECTED endpoints (JWT + CSRF) ─────────────────────────────────────────
|
||||
const protect = [authMiddleware, csrfMiddleware];
|
||||
|
||||
app.use('/api/wallets', ...protect, walletRoutes);
|
||||
app.use('/api/relay', ...protect, relayProxyRoutes);
|
||||
app.use('/api/tron', ...protect, tronProxyRoutes);
|
||||
app.use('/api/sol/swap', ...protect, solSwapProxyRoutes);
|
||||
app.use('/api/tron/swap', ...protect, tronSwapProxyRoutes);
|
||||
app.use('/api/btc', ...protect, btcProxyRoutes);
|
||||
app.use('/api/bsc/swap', ...protect, bscSwapProxyRoutes);
|
||||
// Sensitive (send / vault) — самый строгий лимит
|
||||
app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter);
|
||||
app.use('/api/vault', ...protect, sensitiveLimiter, vaultRoutes);
|
||||
|
||||
// Mutating (создание кошельков / broadcast / build) — повышенный лимит
|
||||
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
|
||||
app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes);
|
||||
app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes);
|
||||
app.use('/api/sol/swap', ...protect, mutateLimiter, solSwapProxyRoutes);
|
||||
app.use('/api/tron/swap', ...protect, mutateLimiter, tronSwapProxyRoutes);
|
||||
app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes);
|
||||
app.use('/api/bsc/swap', ...protect, mutateLimiter, bscSwapProxyRoutes);
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ export let env = {
|
||||
csrfPath: p.VAULT_CSRF_PATH || '',
|
||||
},
|
||||
cors: {
|
||||
origins: (p.CORS_ORIGINS || 'http://localhost:3000').split(','),
|
||||
// No default — каждый деплой явно указывает CORS_ORIGINS, иначе пустой массив = нет cross-origin доступа.
|
||||
origins: (p.CORS_ORIGINS || '').split(',').map((o) => o.trim()).filter(Boolean),
|
||||
allowCredentials: p.CORS_ALLOW_CREDENTIALS !== 'false',
|
||||
},
|
||||
port: parseInt(p.API_PORT || '3001'),
|
||||
@@ -40,6 +41,8 @@ export let env = {
|
||||
jupiterApiKey: p.JUPITER_API_KEY || null,
|
||||
jupiterReferralAccount: p.JUPITER_REFERRAL_ACCOUNT || null,
|
||||
jupiterFeeBps: parseInt(p.JUPITER_FEE_BPS || '70'),
|
||||
etherscanApiKey: p.ETHERSCAN_API_KEY || null,
|
||||
bscscanApiKey: p.BSCSCAN_API_KEY || null,
|
||||
};
|
||||
|
||||
let vaultToken: string | null = null;
|
||||
|
||||
67
apps/api/src/controllers/vault.controller.ts
Normal file
67
apps/api/src/controllers/vault.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { UserModel } from '../models/user.model';
|
||||
|
||||
const MAX_VAULT_SIZE = 8192; // base64 encrypted blob upper limit
|
||||
const MAX_SALT_LEN = 128;
|
||||
|
||||
/**
|
||||
* Encrypted vault — opaque blob (зашифрованный mnemonic, AES-GCM на клиенте).
|
||||
* Сервис хранит как есть; никогда не расшифровывает. Ключ только у клиента
|
||||
* (PBKDF2(password+pin) или аналог).
|
||||
*/
|
||||
export const VaultController = {
|
||||
/**
|
||||
* GET /api/vault — вернуть encrypted_vault + vault_salt пользователя.
|
||||
*/
|
||||
async getVault(req: Request, res: Response) {
|
||||
const userId = req.auth!.userId;
|
||||
try {
|
||||
const row = await UserModel.getVault(userId);
|
||||
if (!row || !row.encrypted_vault || !row.vault_salt) {
|
||||
res.status(404).json({ success: false, error: 'Vault not found' });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
encryptedVault: row.encrypted_vault,
|
||||
vaultSalt: row.vault_salt,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/vault — сохранить новый encrypted_vault + vault_salt.
|
||||
* Создаёт user-row если её ещё нет.
|
||||
*/
|
||||
async putVault(req: Request, res: Response) {
|
||||
const userId = req.auth!.userId;
|
||||
const { encryptedVault, vaultSalt } = req.body ?? {};
|
||||
|
||||
if (typeof encryptedVault !== 'string' || encryptedVault.length === 0 || encryptedVault.length > MAX_VAULT_SIZE) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `encryptedVault must be a non-empty string (max ${MAX_VAULT_SIZE} chars)`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (typeof vaultSalt !== 'string' || vaultSalt.length === 0 || vaultSalt.length > MAX_SALT_LEN) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `vaultSalt must be a non-empty string (max ${MAX_SALT_LEN} chars)`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await UserModel.ensureExists(userId);
|
||||
await UserModel.setVault(userId, encryptedVault, vaultSalt);
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,22 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { WalletModel } from '../models/wallet.model';
|
||||
import { UserModel } from '../models/user.model';
|
||||
import { getBalance, getTransactions, buildSend } from '../services/wallet-ops.service';
|
||||
import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators';
|
||||
|
||||
const ALLOWED_CHAINS = new Set<ChainCode>(['ETH', 'BTC', 'SOL', 'TRX', 'BSC']);
|
||||
const MAX_WALLETS_PER_REQUEST = 20;
|
||||
const MAX_DERIVATION_PATH = 64;
|
||||
const MAX_TX_LIMIT = 100;
|
||||
|
||||
function isChain(value: unknown): value is ChainCode {
|
||||
return typeof value === 'string' && ALLOWED_CHAINS.has(value as ChainCode);
|
||||
}
|
||||
|
||||
export const WalletController = {
|
||||
/**
|
||||
* GET /api/wallets — все кошельки юзера
|
||||
*/
|
||||
async getWallets(req: Request, res: Response) {
|
||||
try {
|
||||
const wallets = await WalletModel.findByUserId(req.auth!.userId);
|
||||
@@ -13,8 +28,182 @@ export const WalletController = {
|
||||
derivationPath: w.derivation_path,
|
||||
})),
|
||||
});
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: 'Internal error' });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/wallets — upsert массива кошельков для юзера из JWT.
|
||||
*/
|
||||
async createWallets(req: Request, res: Response) {
|
||||
const userId = req.auth!.userId;
|
||||
const { wallets } = req.body ?? {};
|
||||
|
||||
if (!Array.isArray(wallets) || wallets.length === 0 || wallets.length > MAX_WALLETS_PER_REQUEST) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `wallets must be a non-empty array (max ${MAX_WALLETS_PER_REQUEST})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const w of wallets) {
|
||||
if (!w || typeof w !== 'object') {
|
||||
res.status(400).json({ success: false, error: 'Invalid wallet entry' });
|
||||
return;
|
||||
}
|
||||
if (!isChain(w.chain)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid chain' });
|
||||
return;
|
||||
}
|
||||
if (!isValidAddress(w.chain, w.address)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid address format for chain' });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof w.derivationPath !== 'string' ||
|
||||
w.derivationPath.length === 0 ||
|
||||
w.derivationPath.length > MAX_DERIVATION_PATH ||
|
||||
!/^m(\/[0-9]+'?)*$/.test(w.derivationPath)
|
||||
) {
|
||||
res.status(400).json({ success: false, error: 'Invalid derivationPath (BIP32 m/.. format expected)' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await UserModel.ensureExists(userId);
|
||||
|
||||
const rows = await WalletModel.upsertMany(
|
||||
wallets.map((w: { chain: ChainCode; address: string; derivationPath: string }) => ({
|
||||
user_id: userId,
|
||||
chain: w.chain,
|
||||
address: w.address,
|
||||
derivation_path: w.derivationPath,
|
||||
}))
|
||||
);
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: rows.map((w) => ({
|
||||
chain: w.chain,
|
||||
address: w.address,
|
||||
derivationPath: w.derivation_path,
|
||||
})),
|
||||
});
|
||||
} catch {
|
||||
res.status(500).json({ success: false, error: 'Internal error' });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/wallets/:chain/balance — баланс для адреса юзера в данной chain.
|
||||
*/
|
||||
async getChainBalance(req: Request, res: Response) {
|
||||
const userId = req.auth!.userId;
|
||||
const chain = String(req.params.chain).toUpperCase();
|
||||
|
||||
if (!isChain(chain)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid chain parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const wallet = await WalletModel.findByUserAndChain(userId, chain);
|
||||
if (!wallet) {
|
||||
res.status(404).json({ success: false, error: 'Wallet not found for this chain' });
|
||||
return;
|
||||
}
|
||||
|
||||
const balance = await getBalance(chain, wallet.address);
|
||||
res.json({ success: true, data: balance });
|
||||
} catch {
|
||||
res.status(502).json({ success: false, error: 'Upstream RPC error' });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/wallets/:chain/transactions
|
||||
*/
|
||||
async getChainTransactions(req: Request, res: Response) {
|
||||
const userId = req.auth!.userId;
|
||||
const chain = String(req.params.chain).toUpperCase();
|
||||
|
||||
if (!isChain(chain)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid chain parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20')) || 20, 1), MAX_TX_LIMIT);
|
||||
|
||||
try {
|
||||
const wallet = await WalletModel.findByUserAndChain(userId, chain);
|
||||
if (!wallet) {
|
||||
res.status(404).json({ success: false, error: 'Wallet not found for this chain' });
|
||||
return;
|
||||
}
|
||||
|
||||
const txs = await getTransactions(chain, wallet.address, limit);
|
||||
res.json({ success: true, data: txs });
|
||||
} catch {
|
||||
res.status(502).json({ success: false, error: 'Upstream RPC error' });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/wallets/:chain/send — build unsigned транзакцию.
|
||||
*/
|
||||
async sendFromChain(req: Request, res: Response) {
|
||||
const userId = req.auth!.userId;
|
||||
const chain = String(req.params.chain).toUpperCase();
|
||||
|
||||
if (!isChain(chain)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid chain parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { to, amount, token } = req.body ?? {};
|
||||
|
||||
if (!isValidAddress(chain, String(to))) {
|
||||
res.status(400).json({ success: false, error: 'Invalid recipient address for chain' });
|
||||
return;
|
||||
}
|
||||
if (!isValidAmount(String(amount))) {
|
||||
res.status(400).json({ success: false, error: 'Invalid amount (must be positive integer string)' });
|
||||
return;
|
||||
}
|
||||
|
||||
let normalizedToken: string | undefined;
|
||||
if (token !== undefined && token !== null) {
|
||||
if (typeof token !== 'string' || !/^[A-Z0-9]{2,10}$/.test(token)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid token symbol' });
|
||||
return;
|
||||
}
|
||||
normalizedToken = token.toUpperCase();
|
||||
}
|
||||
|
||||
try {
|
||||
const wallet = await WalletModel.findByUserAndChain(userId, chain);
|
||||
if (!wallet) {
|
||||
res.status(404).json({ success: false, error: 'Wallet not found for this chain' });
|
||||
return;
|
||||
}
|
||||
|
||||
const tx = await buildSend({
|
||||
chain,
|
||||
from: wallet.address,
|
||||
to,
|
||||
amount,
|
||||
token: normalizedToken,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: tx });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
// Не возвращаем raw upstream message — может содержать sensitive info
|
||||
const safeMsg = err?.message?.toLowerCase().includes('not implemented')
|
||||
? 'Send not supported for this chain/token combination'
|
||||
: 'Failed to build transaction';
|
||||
res.status(502).json({ success: false, error: safeMsg });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
39
apps/api/src/lib/address-validators.ts
Normal file
39
apps/api/src/lib/address-validators.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Chain-specific address format validators.
|
||||
* НЕ заменяет реальную чеканку checksum — это первый barrier.
|
||||
*/
|
||||
import { ethers } from 'ethers';
|
||||
|
||||
const BTC_RE = /^(bc1[a-zA-HJ-NP-Z0-9]{25,62}|[13][a-km-zA-HJ-NP-Z1-9]{25,34})$/;
|
||||
const TRX_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
|
||||
const SOL_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
||||
|
||||
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
||||
|
||||
export function isValidAddress(chain: ChainCode, address: string): boolean {
|
||||
if (typeof address !== 'string' || address.length === 0 || address.length > 256) return false;
|
||||
|
||||
switch (chain) {
|
||||
case 'BTC':
|
||||
return BTC_RE.test(address);
|
||||
case 'TRX':
|
||||
return TRX_RE.test(address);
|
||||
case 'ETH':
|
||||
case 'BSC':
|
||||
return ethers.utils.isAddress(address);
|
||||
case 'SOL':
|
||||
return SOL_RE.test(address);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidAmount(amount: string): boolean {
|
||||
if (typeof amount !== 'string' || amount.length === 0 || amount.length > 78) return false;
|
||||
if (!/^\d+$/.test(amount)) return false;
|
||||
try {
|
||||
return BigInt(amount) > 0n;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,25 @@ function getCallerInfo(): { file: string; line: number } {
|
||||
return { file: 'unknown', line: 0 };
|
||||
}
|
||||
|
||||
// Удаляем потенциально чувствительные значения из лог-сообщений.
|
||||
// Защита от случайной утечки Vault tokens / passwords / secrets через сообщения ошибок.
|
||||
const SENSITIVE_PATTERNS: { regex: RegExp; replace: string }[] = [
|
||||
{ regex: /(password|passwd|pwd)\s*[:=]\s*\S+/gi, replace: '$1=***' },
|
||||
{ regex: /(token|secret|api[_-]?key|auth)\s*[:=]\s*\S+/gi, replace: '$1=***' },
|
||||
{ regex: /\bhvs\.[A-Za-z0-9]+/g, replace: 'hvs.***' },
|
||||
{ regex: /\bs\.[A-Za-z0-9]{20,}/g, replace: 's.***' },
|
||||
{ regex: /Bearer\s+[A-Za-z0-9._-]+/gi, replace: 'Bearer ***' },
|
||||
{ regex: /Authorization:\s*\S+/gi, replace: 'Authorization: ***' },
|
||||
];
|
||||
|
||||
function sanitize(msg: string): string {
|
||||
let out = msg;
|
||||
for (const { regex, replace } of SENSITIVE_PATTERNS) {
|
||||
out = out.replace(regex, replace);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function log(level: string, message: string): void {
|
||||
if ((LEVELS[level] ?? 0) < MIN_LEVEL) return;
|
||||
|
||||
@@ -41,7 +60,7 @@ function log(level: string, message: string): void {
|
||||
file: caller.file,
|
||||
line: caller.line,
|
||||
trace_id: getTraceId(),
|
||||
message,
|
||||
message: sanitize(message),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(entry) + '\n');
|
||||
}
|
||||
|
||||
@@ -11,16 +11,18 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction):
|
||||
return;
|
||||
}
|
||||
|
||||
// CSRF отключён если VAULT_CSRF_PATH не задан
|
||||
// Если CSRF полностью отключён через ENV (csrfPath пустой) — пропускаем.
|
||||
// Это явная конфигурация, не fail-open.
|
||||
if (!env.vault.csrfPath) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Секрет не загрузился (Vault недоступен) — пропускаем чтобы не блокировать сервис
|
||||
// CSRF включён, но секрет не загружен (Vault недоступен) → fail-secure: 503.
|
||||
// НИКОГДА не пропускаем mutating запросы при не-валидном состоянии.
|
||||
if (!isCsrfConfigured()) {
|
||||
logger.warn('CSRF check skipped: secret not loaded');
|
||||
next();
|
||||
logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request');
|
||||
res.status(503).json({ success: false, error: 'CSRF protection unavailable, retry later' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,39 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
|
||||
logger.error(err.message);
|
||||
interface HttpError extends Error {
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic error handler. Sanitization of err.message происходит в logger.
|
||||
* Клиенту НИКОГДА не отдаём raw err.message (может содержать sensitive data).
|
||||
*/
|
||||
export function errorHandler(err: HttpError, _req: Request, res: Response, _next: NextFunction): void {
|
||||
const status = err.status || err.statusCode || 500;
|
||||
|
||||
// Standard Express body-parser errors
|
||||
if (err.type === 'entity.too.large') {
|
||||
logger.warn(`Payload too large: ${err.message}`);
|
||||
res.status(413).json({ success: false, error: 'Payload too large' });
|
||||
return;
|
||||
}
|
||||
if (err.type === 'entity.parse.failed') {
|
||||
logger.warn(`Invalid JSON: ${err.message}`);
|
||||
res.status(400).json({ success: false, error: 'Invalid JSON' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Известные клиентские ошибки (4xx) — отдаём safe сообщение
|
||||
if (status >= 400 && status < 500) {
|
||||
logger.warn(`Client error ${status}: ${err.message}`);
|
||||
res.status(status).json({ success: false, error: 'Bad request' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Серверные ошибки (5xx) — generic message, детали только в логи
|
||||
logger.error(`Server error: ${err.message}`);
|
||||
res.status(500).json({ success: false, error: 'Internal server error' });
|
||||
}
|
||||
|
||||
42
apps/api/src/middleware/rate-limit.ts
Normal file
42
apps/api/src/middleware/rate-limit.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Per-user rate limiting (если auth есть, то по userId; иначе по IP).
|
||||
* Защищает от brute force / DoS / API quota exhaustion.
|
||||
*/
|
||||
function keyByUserOrIp(req: Request, res: any): string {
|
||||
if (req.auth?.userId) return `user:${req.auth.userId}`;
|
||||
// ipKeyGenerator корректно нормализует IPv6 (по /64 префиксу)
|
||||
return ipKeyGenerator(req.ip || '');
|
||||
}
|
||||
|
||||
// Глобальный лимит на любые API запросы — защита от мусорного трафика
|
||||
export const globalLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: 120,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: keyByUserOrIp,
|
||||
message: { success: false, error: 'Too many requests' },
|
||||
});
|
||||
|
||||
// Жёсткий лимит на mutating операции с балансами/wallet
|
||||
export const mutateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: 30,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: keyByUserOrIp,
|
||||
message: { success: false, error: 'Too many mutating requests' },
|
||||
});
|
||||
|
||||
// Самый строгий — для send / vault PUT (anti-abuse / spam tx prevention)
|
||||
export const sensitiveLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: 10,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: keyByUserOrIp,
|
||||
message: { success: false, error: 'Too many sensitive requests' },
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { db } from '../config/database';
|
||||
import { generateUlid } from '../utils/ulid';
|
||||
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
@@ -18,32 +17,62 @@ export interface UserRow {
|
||||
kyc_verified: boolean;
|
||||
kyc_verified_at: Date | null;
|
||||
is_deleted: boolean;
|
||||
encrypted_vault: string | null;
|
||||
vault_salt: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export const UserModel = {
|
||||
async findByEmail(email: string): Promise<UserRow | undefined> {
|
||||
return db('users').where({ email, is_deleted: false }).first();
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<UserRow | undefined> {
|
||||
return db('users').where({ id, is_deleted: false }).first();
|
||||
},
|
||||
|
||||
async create(data: {
|
||||
email: string;
|
||||
password_hash: string;
|
||||
}): Promise<UserRow> {
|
||||
const [user] = await db('users').insert({ id: generateUlid(), ...data }).returning('*');
|
||||
return user;
|
||||
async findByEmail(email: string): Promise<UserRow | undefined> {
|
||||
return db('users').where({ email, is_deleted: false }).first();
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<Omit<UserRow, 'id' | 'created_at'>>): Promise<UserRow | undefined> {
|
||||
/**
|
||||
* Создать запись пользователя если её нет.
|
||||
* id берётся из JWT (sub). Email/password_hash — заглушки, потому что реальный
|
||||
* учёт авторизации в BITOK; мы только проксируем wallet-специфичные данные.
|
||||
*/
|
||||
async ensureExists(id: string): Promise<void> {
|
||||
await db('users')
|
||||
.insert({
|
||||
id,
|
||||
email: `${id}@elcsa.local`,
|
||||
password_hash: 'EXTERNAL_AUTH',
|
||||
})
|
||||
.onConflict('id')
|
||||
.ignore();
|
||||
},
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Omit<UserRow, 'id' | 'created_at'>>,
|
||||
): Promise<UserRow | undefined> {
|
||||
const [user] = await db('users')
|
||||
.where({ id })
|
||||
.update({ ...data, updated_at: db.fn.now() })
|
||||
.returning('*');
|
||||
return user;
|
||||
},
|
||||
|
||||
async setVault(id: string, encryptedVault: string, vaultSalt: string): Promise<void> {
|
||||
await db('users')
|
||||
.where({ id })
|
||||
.update({
|
||||
encrypted_vault: encryptedVault,
|
||||
vault_salt: vaultSalt,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
},
|
||||
|
||||
async getVault(id: string): Promise<{ encrypted_vault: string | null; vault_salt: string | null } | undefined> {
|
||||
return db('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.select('encrypted_vault', 'vault_salt')
|
||||
.first();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,10 +15,29 @@ export const WalletModel = {
|
||||
return db('wallets').where({ user_id: userId });
|
||||
},
|
||||
|
||||
async findByUserAndChain(userId: string, chain: string): Promise<WalletRow | undefined> {
|
||||
return db('wallets').where({ user_id: userId, chain }).first();
|
||||
},
|
||||
|
||||
async createMany(
|
||||
wallets: { user_id: string; chain: string; address: string; derivation_path: string }[]
|
||||
): Promise<WalletRow[]> {
|
||||
const withIds = wallets.map((w) => ({ id: generateUlid(), ...w }));
|
||||
return db('wallets').insert(withIds).returning('*');
|
||||
},
|
||||
|
||||
/**
|
||||
* Insert wallets, on conflict (user_id, chain) update address + derivation_path.
|
||||
* Used by POST /api/wallets — клиент шлёт массив адресов после регистрации в BITOK.
|
||||
*/
|
||||
async upsertMany(
|
||||
wallets: { user_id: string; chain: string; address: string; derivation_path: string }[]
|
||||
): Promise<WalletRow[]> {
|
||||
const withIds = wallets.map((w) => ({ id: generateUlid(), ...w }));
|
||||
return db('wallets')
|
||||
.insert(withIds)
|
||||
.onConflict(['user_id', 'chain'])
|
||||
.merge(['address', 'derivation_path'])
|
||||
.returning('*');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -56,6 +56,12 @@ async function getQuote(req: Request, res: Response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedSlippage = parseInt(String(slippageBps), 10);
|
||||
if (!Number.isFinite(parsedSlippage) || parsedSlippage < 1 || parsedSlippage > 500) {
|
||||
res.status(400).json({ success: false, error: 'slippageBps must be 1-500 (max 5%)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS);
|
||||
|
||||
|
||||
@@ -193,15 +193,55 @@ async function createTransaction(req: Request, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
// Whitelist contracts and functions accepted via /triggersmartcontract.
|
||||
// Defence in depth: иначе клиент мог бы вызвать любой контракт (e.g. drain pool).
|
||||
const ALLOWED_TRC_CONTRACTS = new Set<string>([
|
||||
USDT_CONTRACT, // USDT TRC20
|
||||
'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax', // SunSwap router
|
||||
'TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E', // FeeSwapRouter_TRX
|
||||
'TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR', // WTRX
|
||||
]);
|
||||
|
||||
const ALLOWED_TRC_FUNCTIONS = new Set<string>([
|
||||
'transfer(address,uint256)',
|
||||
'approve(address,uint256)',
|
||||
'balanceOf(address)',
|
||||
'allowance(address,address)',
|
||||
'swapExactETHForTokens(uint256,address[],address,uint256)',
|
||||
'swapExactTokensForETH(uint256,uint256,address[],address,uint256)',
|
||||
'swapNativeWithFee(bytes)',
|
||||
'swapTokenWithFee(address,uint256,bytes)',
|
||||
'getAmountsOut(uint256,address[])',
|
||||
]);
|
||||
|
||||
/**
|
||||
* POST /api/tron/triggersmartcontract
|
||||
* Proxies to TronGrid /wallet/triggersmartcontract — builds unsigned transaction
|
||||
* Proxies to TronGrid /wallet/triggersmartcontract — builds unsigned transaction.
|
||||
* Whitelisted contracts + function selectors only.
|
||||
*/
|
||||
async function triggerSmartContract(req: Request, res: Response) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const body = req.body ?? {};
|
||||
const contractAddress = String(body.contract_address || '');
|
||||
const functionSelector = String(body.function_selector || '');
|
||||
const ownerAddress = String(body.owner_address || '');
|
||||
|
||||
if (!ALLOWED_TRC_CONTRACTS.has(contractAddress)) {
|
||||
res.status(403).json({ success: false, error: 'Contract address not allowed' });
|
||||
return;
|
||||
}
|
||||
if (!ALLOWED_TRC_FUNCTIONS.has(functionSelector)) {
|
||||
res.status(403).json({ success: false, error: 'Function selector not allowed' });
|
||||
return;
|
||||
}
|
||||
if (!TRON_ADDRESS_RE.test(ownerAddress)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid owner_address' });
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
|
||||
9
apps/api/src/routes/vault.routes.ts
Normal file
9
apps/api/src/routes/vault.routes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { VaultController } from '../controllers/vault.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', VaultController.getVault);
|
||||
router.put('/', VaultController.putVault);
|
||||
|
||||
export default router;
|
||||
@@ -4,5 +4,10 @@ import { WalletController } from '../controllers/wallet.controller';
|
||||
const router = Router();
|
||||
|
||||
router.get('/', WalletController.getWallets);
|
||||
router.post('/', WalletController.createWallets);
|
||||
|
||||
router.get('/:chain/balance', WalletController.getChainBalance);
|
||||
router.get('/:chain/transactions', WalletController.getChainTransactions);
|
||||
router.post('/:chain/send', WalletController.sendFromChain);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
import { logger } from '../lib/logger';
|
||||
import { fetchVaultKV2 } from '../config/vault';
|
||||
|
||||
/**
|
||||
* CSRF token validation compatible with Python's `itsdangerous`
|
||||
@@ -18,57 +18,70 @@ import { logger } from '../lib/logger';
|
||||
|
||||
const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time
|
||||
|
||||
let csrfSecret: string | null = null;
|
||||
let csrfSalt = 'itsdangerous.Signer';
|
||||
let csrfDigest: 'sha1' | 'sha256' | 'sha512' = 'sha512';
|
||||
let csrfMaxAgeSec = 60 * 60 * 24 * 7; // 7 days
|
||||
export interface CsrfConfig {
|
||||
secret: string;
|
||||
salt: string;
|
||||
digest: 'sha1' | 'sha256' | 'sha512';
|
||||
maxAgeSec: number;
|
||||
}
|
||||
|
||||
export async function loadCsrfSecret(
|
||||
// Live config — атомарно подменяется через swapCsrfConfig()
|
||||
let current: CsrfConfig | null = null;
|
||||
|
||||
export function swapCsrfConfig(cfg: CsrfConfig | null): void {
|
||||
current = cfg;
|
||||
}
|
||||
|
||||
export function isCsrfConfigured(): boolean {
|
||||
return current !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fetch CSRF config из Vault — НЕ мутирует глобал, возвращает новый объект.
|
||||
*/
|
||||
export async function fetchCsrfConfig(
|
||||
addr: string,
|
||||
token: string,
|
||||
mount: string,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
const { fetchVaultKV2 } = await import('../config/vault');
|
||||
|
||||
): Promise<CsrfConfig> {
|
||||
const secrets = await fetchVaultKV2(addr, token, mount, path);
|
||||
if (!secrets) {
|
||||
logger.warn('Failed to load CSRF secret from Vault');
|
||||
return;
|
||||
throw new Error('Failed to load CSRF secret from Vault');
|
||||
}
|
||||
|
||||
const secret = secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret;
|
||||
if (!secret) {
|
||||
logger.warn('CSRF secret not found in Vault payload (expected key: secret_key)');
|
||||
return;
|
||||
const secret =
|
||||
secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret;
|
||||
if (!secret || typeof secret !== 'string' || secret.length < 32) {
|
||||
throw new Error('CSRF secret invalid: must be string >= 32 chars');
|
||||
}
|
||||
|
||||
csrfSecret = secret;
|
||||
if (secrets.salt) csrfSalt = secrets.salt;
|
||||
const salt = secrets.salt || 'itsdangerous.Signer';
|
||||
if (typeof salt !== 'string' || salt.length < 8) {
|
||||
throw new Error('CSRF salt invalid: must be string >= 8 chars');
|
||||
}
|
||||
|
||||
let digest: 'sha1' | 'sha256' | 'sha512' = 'sha512';
|
||||
if (secrets.digest === 'sha1' || secrets.digest === 'sha256' || secrets.digest === 'sha512') {
|
||||
csrfDigest = secrets.digest as 'sha1' | 'sha256' | 'sha512';
|
||||
digest = secrets.digest;
|
||||
}
|
||||
|
||||
let maxAgeSec = 60 * 60 * 24 * 7; // 7 days
|
||||
if (secrets.max_age_sec) {
|
||||
const n = parseInt(secrets.max_age_sec);
|
||||
if (!Number.isNaN(n) && n > 0) csrfMaxAgeSec = n;
|
||||
if (!Number.isNaN(n) && n > 0) maxAgeSec = n;
|
||||
}
|
||||
|
||||
logger.info(`CSRF secret loaded (digest=${csrfDigest}, salt="${csrfSalt}", max_age=${csrfMaxAgeSec}s)`);
|
||||
}
|
||||
|
||||
export function isCsrfConfigured(): boolean {
|
||||
return csrfSecret !== null;
|
||||
return { secret, salt, digest, maxAgeSec };
|
||||
}
|
||||
|
||||
function b64urlDecode(s: string): Buffer {
|
||||
// itsdangerous strips padding
|
||||
const pad = s.length % 4 === 0 ? 0 : 4 - (s.length % 4);
|
||||
const padded = s + '='.repeat(pad);
|
||||
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
}
|
||||
|
||||
function deriveKey(secret: string, salt: string, digest: string): Buffer {
|
||||
// itsdangerous `Signer.derive_key`: HMAC(secret, salt + "signer")
|
||||
return crypto.createHmac(digest, secret).update(salt + 'signer').digest();
|
||||
}
|
||||
|
||||
@@ -85,13 +98,13 @@ export interface CsrfVerifyResult {
|
||||
}
|
||||
|
||||
export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||
if (!csrfSecret) return { valid: false, reason: 'CSRF secret not loaded' };
|
||||
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
|
||||
if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' };
|
||||
|
||||
const lastDot = token.lastIndexOf('.');
|
||||
if (lastDot < 0) return { valid: false, reason: 'Malformed token (no signature)' };
|
||||
|
||||
const payloadTs = token.slice(0, lastDot); // "<payload>.<timestamp>"
|
||||
const payloadTs = token.slice(0, lastDot);
|
||||
const sigStr = token.slice(lastDot + 1);
|
||||
|
||||
const prevDot = payloadTs.lastIndexOf('.');
|
||||
@@ -99,8 +112,8 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||
|
||||
const tsStr = payloadTs.slice(prevDot + 1);
|
||||
|
||||
const derived = deriveKey(csrfSecret, csrfSalt, csrfDigest);
|
||||
const expectedSig = crypto.createHmac(csrfDigest, derived).update(payloadTs).digest();
|
||||
const derived = deriveKey(current.secret, current.salt, current.digest);
|
||||
const expectedSig = crypto.createHmac(current.digest, derived).update(payloadTs).digest();
|
||||
|
||||
let actualSig: Buffer;
|
||||
try {
|
||||
@@ -116,12 +129,11 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||
return { valid: false, reason: 'Signature mismatch' };
|
||||
}
|
||||
|
||||
// Timestamp check
|
||||
try {
|
||||
const issuedAt = decodeTimestamp(tsStr);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (issuedAt > now + 60) return { valid: false, reason: 'Token from the future' };
|
||||
if (now - issuedAt > csrfMaxAgeSec) return { valid: false, reason: 'Token expired' };
|
||||
if (now - issuedAt > current.maxAgeSec) return { valid: false, reason: 'Token expired' };
|
||||
} catch {
|
||||
return { valid: false, reason: 'Invalid timestamp' };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as jose from 'jose';
|
||||
import { env } from '../config/env';
|
||||
import { fetchVaultKV2 } from '../config/vault';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export interface AccessTokenPayload {
|
||||
@@ -19,21 +20,41 @@ export interface AuthContext {
|
||||
token: AccessTokenPayload;
|
||||
}
|
||||
|
||||
const keyMap = new Map<string, Awaited<ReturnType<typeof jose.importSPKI>>>();
|
||||
type KeyType = Awaited<ReturnType<typeof jose.importSPKI>>;
|
||||
|
||||
export async function loadJwtKeysFromVault(
|
||||
// Whitelist надёжных асимметричных алгоритмов. Никогда не разрешаем 'none'/HS*
|
||||
// (HS — симметричные, могли бы быть подставлены через algorithm confusion).
|
||||
const ALLOWED_ALGORITHMS = new Set(['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'EdDSA', 'PS256', 'PS384', 'PS512']);
|
||||
|
||||
if (!ALLOWED_ALGORITHMS.has(env.jwt.algorithm)) {
|
||||
throw new Error(`JWT_ALGORITHM "${env.jwt.algorithm}" not allowed. Use one of: ${[...ALLOWED_ALGORITHMS].join(', ')}`);
|
||||
}
|
||||
|
||||
// Live key store — атомарно подменяется через swapKeyMap()
|
||||
let keyMap: Map<string, KeyType> = new Map();
|
||||
|
||||
export function swapKeyMap(newMap: Map<string, KeyType>): void {
|
||||
keyMap = newMap;
|
||||
}
|
||||
|
||||
export function getKeyMapSize(): number {
|
||||
return keyMap.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fetch JWT public keys from Vault, не мутируя глобальный keyMap.
|
||||
* Возвращает новую Map для атомарного swap'а.
|
||||
*/
|
||||
export async function fetchJwtKeysFromVault(
|
||||
vaultAddr: string,
|
||||
vaultToken: string,
|
||||
mount: string,
|
||||
kidPath: string,
|
||||
kidsPrefix: string,
|
||||
): Promise<void> {
|
||||
const { fetchVaultKV2 } = await import('../config/vault');
|
||||
|
||||
): Promise<Map<string, KeyType>> {
|
||||
const kidData = await fetchVaultKV2(vaultAddr, vaultToken, mount, kidPath);
|
||||
if (!kidData) {
|
||||
logger.warn('Failed to read JWT kid config from Vault');
|
||||
return;
|
||||
throw new Error('Failed to read JWT kid config from Vault');
|
||||
}
|
||||
|
||||
const kids: string[] = [];
|
||||
@@ -41,10 +62,11 @@ export async function loadJwtKeysFromVault(
|
||||
if (kidData.previous && kidData.previous !== kidData.active) kids.push(kidData.previous);
|
||||
|
||||
if (kids.length === 0) {
|
||||
logger.warn('No active/previous kids found in Vault');
|
||||
return;
|
||||
throw new Error('No active/previous kids found in Vault');
|
||||
}
|
||||
|
||||
const next = new Map<string, KeyType>();
|
||||
|
||||
for (const kid of kids) {
|
||||
const kidSecret = await fetchVaultKV2(vaultAddr, vaultToken, mount, `${kidsPrefix}/${kid}`);
|
||||
if (!kidSecret?.public_key) {
|
||||
@@ -52,16 +74,15 @@ export async function loadJwtKeysFromVault(
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
|
||||
keyMap.set(kid, key);
|
||||
logger.info(`Loaded JWT public key for kid=${kid}`);
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to import public key for kid=${kid}: ${err.message}`);
|
||||
}
|
||||
next.set(kid, key);
|
||||
}
|
||||
|
||||
logger.info(`JWT key store loaded: ${keyMap.size} key(s)`);
|
||||
if (next.size === 0) {
|
||||
throw new Error('No public keys could be loaded from Vault');
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(token: string): Promise<AuthContext> {
|
||||
@@ -71,17 +92,17 @@ export async function verifyAccessToken(token: string): Promise<AuthContext> {
|
||||
const header = jose.decodeProtectedHeader(token);
|
||||
const kid = header.kid;
|
||||
|
||||
if (!kid) {
|
||||
throw Object.assign(new Error('Missing kid in token header'), { status: 401 });
|
||||
if (!kid || typeof kid !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(kid)) {
|
||||
throw Object.assign(new Error('Missing or invalid kid in token header'), { status: 401 });
|
||||
}
|
||||
|
||||
const key = keyMap.get(kid);
|
||||
if (!key) {
|
||||
logger.warn(`Unknown kid=${kid}`);
|
||||
throw Object.assign(new Error('Unknown token kid'), { status: 401 });
|
||||
}
|
||||
|
||||
if (header.alg !== env.jwt.algorithm) {
|
||||
// Двойная защита от algorithm confusion: проверяем точное совпадение
|
||||
if (header.alg !== env.jwt.algorithm || !ALLOWED_ALGORITHMS.has(String(header.alg))) {
|
||||
throw Object.assign(new Error('Invalid token algorithm'), { status: 401 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { env, getVaultToken } from '../config/env';
|
||||
import { vaultAppRoleLogin } from '../config/vault';
|
||||
import { loadJwtKeysFromVault } from './jwt.service';
|
||||
import { loadCsrfSecret } from './csrf.service';
|
||||
import { fetchJwtKeysFromVault, swapKeyMap, getKeyMapSize } from './jwt.service';
|
||||
import { fetchCsrfConfig, swapCsrfConfig } from './csrf.service';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
@@ -10,9 +10,8 @@ let timer: NodeJS.Timeout | null = null;
|
||||
let currentVaultToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Refresh JWT public keys (active + previous) and CSRF secret from Vault.
|
||||
* Errors are logged but do NOT throw — старые значения остаются в памяти,
|
||||
* сервис продолжает работать до следующего успешного refresh.
|
||||
* Atomic refresh: pre-fetch JWT keys + CSRF config, swap globals only if BOTH succeed.
|
||||
* При любой ошибке оставляем старые значения в памяти, сервис продолжает работать.
|
||||
*/
|
||||
export async function refreshAllKeys(): Promise<void> {
|
||||
const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath } = env.vault;
|
||||
@@ -22,7 +21,7 @@ export async function refreshAllKeys(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use token from initEnv first call; re-login only if we don't have one yet.
|
||||
// Vault token: используем закэшированный из initEnv, либо логинимся заново
|
||||
let token = currentVaultToken || getVaultToken();
|
||||
if (!token) {
|
||||
const fresh = await vaultAppRoleLogin(addr, roleId, secretId);
|
||||
@@ -34,19 +33,32 @@ export async function refreshAllKeys(): Promise<void> {
|
||||
currentVaultToken = fresh;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to refresh JWT keys: ${err.message}`);
|
||||
// ── Pre-fetch обоих секретов параллельно (НЕ мутируя глобал) ───────────
|
||||
const jwtPromise = fetchJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
|
||||
const csrfPromise = csrfPath ? fetchCsrfConfig(addr, token, mount, csrfPath) : Promise.resolve(null);
|
||||
|
||||
const [jwtResult, csrfResult] = await Promise.allSettled([jwtPromise, csrfPromise]);
|
||||
|
||||
// ── Атомарность: если хоть один обязательный fetch упал — НИЧЕГО не меняем ──
|
||||
if (jwtResult.status === 'rejected') {
|
||||
logger.error(`Key refresh ABORTED — JWT keys fetch failed: ${jwtResult.reason?.message || jwtResult.reason}`);
|
||||
return;
|
||||
}
|
||||
if (csrfPath && csrfResult.status === 'rejected') {
|
||||
logger.error(`Key refresh ABORTED — CSRF fetch failed: ${csrfResult.reason?.message || csrfResult.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (csrfPath) {
|
||||
try {
|
||||
await loadCsrfSecret(addr, token, mount, csrfPath);
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to refresh CSRF secret: ${err.message}`);
|
||||
}
|
||||
// ── Atomic swap (синхронные операции, нельзя прервать) ──────────────────
|
||||
swapKeyMap(jwtResult.value);
|
||||
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
|
||||
swapCsrfConfig(csrfResult.value);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Keys refreshed atomically: JWT keys=${getKeyMapSize()}` +
|
||||
(csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void {
|
||||
@@ -56,8 +68,7 @@ export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void
|
||||
void refreshAllKeys().catch((err) =>
|
||||
logger.error(`Key rotation tick failed: ${err?.message || err}`)
|
||||
);
|
||||
// On token expiry Vault will return 403 — we need to re-login.
|
||||
// Reset cached token so refreshAllKeys re-logs in on next call.
|
||||
// На каждый тик — invalidate Vault token (он мог истечь), будет re-login
|
||||
currentVaultToken = null;
|
||||
}, intervalMs);
|
||||
logger.info(`Key rotation scheduled (every ${intervalMs}ms)`);
|
||||
|
||||
490
apps/api/src/services/wallet-ops.service.ts
Normal file
490
apps/api/src/services/wallet-ops.service.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Wallet operations across chains: balance, transactions, build unsigned send tx.
|
||||
* Non-custodial: server NEVER signs — клиент подписывает приватом.
|
||||
*/
|
||||
import { ethers } from 'ethers';
|
||||
import { env } from '../config/env';
|
||||
|
||||
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
||||
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
// ── External APIs ──
|
||||
const BLOCKSTREAM = 'https://blockstream.info/api';
|
||||
const TRONGRID = 'https://api.trongrid.io';
|
||||
const BSC_RPC = 'https://bsc-dataseed.binance.org';
|
||||
const ETH_RPC = 'https://ethereum-rpc.publicnode.com';
|
||||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
||||
|
||||
const USDT_TRC20 = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
||||
const USDT_BEP20 = '0x55d398326f99059fF775485246999027B3197955';
|
||||
const USDT_ERC20 = '0xdAC17F958D2ee523a2206206994597C13D831ec7';
|
||||
|
||||
const ERC20_ABI = [
|
||||
'function balanceOf(address owner) view returns (uint256)',
|
||||
'function transfer(address to, uint256 amount) returns (bool)',
|
||||
'function decimals() view returns (uint8)',
|
||||
];
|
||||
|
||||
// ─────────────────────── BALANCE ───────────────────────
|
||||
|
||||
export interface BalanceResult {
|
||||
chain: ChainCode;
|
||||
address: string;
|
||||
native: string; // в smallest units (satoshi/wei/lamports/sun)
|
||||
tokens?: Record<string, string>; // например { USDT: "12345678" }
|
||||
}
|
||||
|
||||
export async function getBalance(chain: ChainCode, address: string): Promise<BalanceResult> {
|
||||
switch (chain) {
|
||||
case 'BTC':
|
||||
return { chain, address, native: await btcBalance(address) };
|
||||
case 'TRX': {
|
||||
const { trx, usdt } = await trxBalance(address);
|
||||
return { chain, address, native: trx, tokens: { USDT: usdt } };
|
||||
}
|
||||
case 'BSC': {
|
||||
const { native, tokens } = await evmBalance(BSC_RPC, address, [{ symbol: 'USDT', addr: USDT_BEP20 }]);
|
||||
return { chain, address, native, tokens };
|
||||
}
|
||||
case 'ETH': {
|
||||
const { native, tokens } = await evmBalance(ETH_RPC, address, [{ symbol: 'USDT', addr: USDT_ERC20 }]);
|
||||
return { chain, address, native, tokens };
|
||||
}
|
||||
case 'SOL':
|
||||
return { chain, address, native: await solBalance(address) };
|
||||
}
|
||||
}
|
||||
|
||||
async function btcBalance(address: string): Promise<string> {
|
||||
const res = await fetchJson(`${BLOCKSTREAM}/address/${address}`);
|
||||
const stats = res.chain_stats;
|
||||
const sat = BigInt(stats.funded_txo_sum) - BigInt(stats.spent_txo_sum);
|
||||
return sat.toString();
|
||||
}
|
||||
|
||||
async function trxBalance(address: string): Promise<{ trx: string; usdt: string }> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
const accRes = await fetchJson(`${TRONGRID}/v1/accounts/${address}`, { headers });
|
||||
const trx = accRes.data?.[0]?.balance ? String(accRes.data[0].balance) : '0';
|
||||
|
||||
// USDT TRC20 balance
|
||||
const usdtRes = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
owner_address: address,
|
||||
contract_address: USDT_TRC20,
|
||||
function_selector: 'balanceOf(address)',
|
||||
parameter: tronAddressToHex(address).padStart(64, '0'),
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
const usdtHex = usdtRes.constant_result?.[0];
|
||||
const usdt = usdtHex && !/^0+$/.test(usdtHex) ? BigInt('0x' + usdtHex).toString() : '0';
|
||||
|
||||
return { trx, usdt };
|
||||
}
|
||||
|
||||
async function evmBalance(
|
||||
rpc: string,
|
||||
address: string,
|
||||
tokens: { symbol: string; addr: string }[],
|
||||
): Promise<{ native: string; tokens: Record<string, string> }> {
|
||||
const provider = new ethers.providers.StaticJsonRpcProvider(rpc);
|
||||
const native = await withTimeout(provider.getBalance(address), TIMEOUT_MS, 'EVM balance timeout');
|
||||
|
||||
const tokenBalances: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
tokens.map(async ({ symbol, addr }) => {
|
||||
try {
|
||||
const c = new ethers.Contract(addr, ERC20_ABI, provider);
|
||||
const bal: ethers.BigNumber = await withTimeout(c.balanceOf(address), TIMEOUT_MS, `${symbol} balance timeout`);
|
||||
tokenBalances[symbol] = bal.toString();
|
||||
} catch {
|
||||
tokenBalances[symbol] = '0';
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { native: native.toString(), tokens: tokenBalances };
|
||||
}
|
||||
|
||||
async function solBalance(address: string): Promise<string> {
|
||||
const res = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getBalance',
|
||||
params: [address],
|
||||
}),
|
||||
});
|
||||
return String(res.result?.value ?? 0);
|
||||
}
|
||||
|
||||
// ─────────────────────── TRANSACTIONS ───────────────────────
|
||||
|
||||
export interface TxItem {
|
||||
txid: string;
|
||||
timestamp: number | null; // unix seconds
|
||||
direction: 'in' | 'out' | 'self';
|
||||
amount?: string;
|
||||
token?: string;
|
||||
to?: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export async function getTransactions(chain: ChainCode, address: string, limit: number): Promise<TxItem[]> {
|
||||
switch (chain) {
|
||||
case 'BTC':
|
||||
return btcTransactions(address, limit);
|
||||
case 'TRX':
|
||||
return trxTransactions(address, limit);
|
||||
case 'BSC':
|
||||
return scanTransactions('https://api.bscscan.com/api', env.bscscanApiKey, address, limit);
|
||||
case 'ETH':
|
||||
return scanTransactions('https://api.etherscan.io/api', env.etherscanApiKey, address, limit);
|
||||
case 'SOL':
|
||||
return solTransactions(address, limit);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanTransactions(
|
||||
apiBase: string,
|
||||
apiKey: string | null,
|
||||
address: string,
|
||||
limit: number,
|
||||
): Promise<TxItem[]> {
|
||||
if (!apiKey) return [];
|
||||
|
||||
const url = new URL(apiBase);
|
||||
url.searchParams.set('module', 'account');
|
||||
url.searchParams.set('action', 'txlist');
|
||||
url.searchParams.set('address', address);
|
||||
url.searchParams.set('startblock', '0');
|
||||
url.searchParams.set('endblock', '99999999');
|
||||
url.searchParams.set('page', '1');
|
||||
url.searchParams.set('offset', String(Math.min(limit, 100)));
|
||||
url.searchParams.set('sort', 'desc');
|
||||
url.searchParams.set('apikey', apiKey);
|
||||
|
||||
const res = await fetchJson(url.toString());
|
||||
if (res.status !== '1' || !Array.isArray(res.result)) return [];
|
||||
|
||||
return (res.result as any[]).slice(0, limit).map((tx) => {
|
||||
const isOut = String(tx.from).toLowerCase() === address.toLowerCase();
|
||||
return {
|
||||
txid: tx.hash,
|
||||
timestamp: tx.timeStamp ? parseInt(tx.timeStamp, 10) : null,
|
||||
direction: (isOut ? 'out' : 'in') as TxItem['direction'],
|
||||
amount: tx.value || undefined,
|
||||
from: tx.from,
|
||||
to: tx.to,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function btcTransactions(address: string, limit: number): Promise<TxItem[]> {
|
||||
const txs = await fetchJson(`${BLOCKSTREAM}/address/${address}/txs`);
|
||||
return (txs as any[]).slice(0, limit).map((tx) => {
|
||||
const inSelf = tx.vin.some((v: any) => v.prevout?.scriptpubkey_address === address);
|
||||
const outSelf = tx.vout.some((v: any) => v.scriptpubkey_address === address);
|
||||
const direction: TxItem['direction'] = inSelf && outSelf ? 'self' : inSelf ? 'out' : 'in';
|
||||
return {
|
||||
txid: tx.txid,
|
||||
timestamp: tx.status?.block_time ?? null,
|
||||
direction,
|
||||
amount: String(
|
||||
tx.vout
|
||||
.filter((v: any) => (direction === 'in' ? v.scriptpubkey_address === address : v.scriptpubkey_address !== address))
|
||||
.reduce((s: bigint, v: any) => s + BigInt(v.value), 0n),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function trxTransactions(address: string, limit: number): Promise<TxItem[]> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
const res = await fetchJson(
|
||||
`${TRONGRID}/v1/accounts/${address}/transactions?limit=${limit}`,
|
||||
{ headers },
|
||||
);
|
||||
return ((res.data as any[]) || []).slice(0, limit).map((tx) => {
|
||||
const contract = tx.raw_data?.contract?.[0];
|
||||
const value = contract?.parameter?.value;
|
||||
const fromAddr = value?.owner_address ? hexToTron(value.owner_address) : '';
|
||||
const toAddr = value?.to_address ? hexToTron(value.to_address) : '';
|
||||
const isOut = fromAddr === address;
|
||||
return {
|
||||
txid: tx.txID,
|
||||
timestamp: tx.block_timestamp ? Math.floor(tx.block_timestamp / 1000) : null,
|
||||
direction: (isOut ? 'out' : 'in') as TxItem['direction'],
|
||||
amount: value?.amount ? String(value.amount) : undefined,
|
||||
from: fromAddr || undefined,
|
||||
to: toAddr || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function solTransactions(address: string, limit: number): Promise<TxItem[]> {
|
||||
const res = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getSignaturesForAddress',
|
||||
params: [address, { limit }],
|
||||
}),
|
||||
});
|
||||
return ((res.result as any[]) || []).map((sig) => ({
|
||||
txid: sig.signature,
|
||||
timestamp: sig.blockTime ?? null,
|
||||
direction: 'self' as const, // без deep parsing — направление неизвестно
|
||||
}));
|
||||
}
|
||||
|
||||
// ─────────────────────── BUILD SEND (UNSIGNED TX) ───────────────────────
|
||||
|
||||
export interface BuildSendParams {
|
||||
chain: ChainCode;
|
||||
from: string;
|
||||
to: string;
|
||||
amount: string;
|
||||
token?: string; // 'USDT' и т.д.; для native перевода — undefined
|
||||
}
|
||||
|
||||
export type UnsignedTx =
|
||||
| { kind: 'btc'; from: string; to: string; amountSat: string; utxos: any[]; feeRateSatPerVb: number }
|
||||
| { kind: 'tron'; transaction: any }
|
||||
| { kind: 'evm'; to: string; data: string; value: string; chainId: number; gasLimit?: string }
|
||||
| { kind: 'solana'; instructions: any; recentBlockhash: string };
|
||||
|
||||
export async function buildSend(p: BuildSendParams): Promise<UnsignedTx> {
|
||||
switch (p.chain) {
|
||||
case 'BTC':
|
||||
return buildBtcSend(p);
|
||||
case 'TRX':
|
||||
return buildTrxSend(p);
|
||||
case 'BSC':
|
||||
return buildEvmSend(p, BSC_RPC, 56, USDT_BEP20);
|
||||
case 'ETH':
|
||||
return buildEvmSend(p, ETH_RPC, 1, USDT_ERC20);
|
||||
case 'SOL':
|
||||
return buildSolSend(p);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildBtcSend(p: BuildSendParams): Promise<UnsignedTx> {
|
||||
if (p.token) throw new Error('BTC tokens not supported');
|
||||
const utxos = await fetchJson(`${BLOCKSTREAM}/address/${p.from}/utxo`);
|
||||
const fees = await fetchJson(`${BLOCKSTREAM}/fee-estimates`);
|
||||
const confirmed = ((utxos as any[]) || []).filter((u) => u.status?.confirmed);
|
||||
|
||||
return {
|
||||
kind: 'btc',
|
||||
from: p.from,
|
||||
to: p.to,
|
||||
amountSat: p.amount,
|
||||
utxos: confirmed.map((u) => ({ txid: u.txid, vout: u.vout, value: u.value })),
|
||||
feeRateSatPerVb: Math.ceil((fees as any)['3'] ?? (fees as any)['6'] ?? 5),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildTrxSend(p: BuildSendParams): Promise<UnsignedTx> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
if (!p.token) {
|
||||
// Native TRX
|
||||
const res = await fetchJson(`${TRONGRID}/wallet/createtransaction`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ owner_address: p.from, to_address: p.to, amount: Number(p.amount), visible: true }),
|
||||
});
|
||||
return { kind: 'tron', transaction: res };
|
||||
}
|
||||
|
||||
if (p.token.toUpperCase() === 'USDT') {
|
||||
// TRC20 USDT
|
||||
const param = tronAddressToHex(p.to).padStart(64, '0') + BigInt(p.amount).toString(16).padStart(64, '0');
|
||||
const res = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
owner_address: p.from,
|
||||
contract_address: USDT_TRC20,
|
||||
function_selector: 'transfer(address,uint256)',
|
||||
parameter: param,
|
||||
fee_limit: 100_000_000,
|
||||
call_value: 0,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
return { kind: 'tron', transaction: res };
|
||||
}
|
||||
|
||||
throw new Error(`Token ${p.token} not supported on TRX`);
|
||||
}
|
||||
|
||||
async function buildEvmSend(p: BuildSendParams, rpc: string, chainId: number, usdtAddr: string): Promise<UnsignedTx> {
|
||||
if (!ethers.utils.isAddress(p.to)) throw new Error('Invalid recipient address');
|
||||
|
||||
if (!p.token) {
|
||||
return { kind: 'evm', to: p.to, data: '0x', value: ethers.BigNumber.from(p.amount).toHexString(), chainId };
|
||||
}
|
||||
|
||||
if (p.token.toUpperCase() === 'USDT') {
|
||||
const iface = new ethers.utils.Interface(ERC20_ABI);
|
||||
const data = iface.encodeFunctionData('transfer', [p.to, p.amount]);
|
||||
return { kind: 'evm', to: usdtAddr, data, value: '0x0', chainId };
|
||||
}
|
||||
|
||||
throw new Error(`Token ${p.token} not supported on ${chainId === 56 ? 'BSC' : 'ETH'}`);
|
||||
}
|
||||
|
||||
async function buildSolSend(p: BuildSendParams): Promise<UnsignedTx> {
|
||||
const {
|
||||
Connection,
|
||||
PublicKey,
|
||||
SystemProgram,
|
||||
Transaction,
|
||||
} = await import('@solana/web3.js');
|
||||
|
||||
const conn = new Connection(SOL_RPC, 'confirmed');
|
||||
|
||||
let fromPk: InstanceType<typeof PublicKey>;
|
||||
let toPk: InstanceType<typeof PublicKey>;
|
||||
try {
|
||||
fromPk = new PublicKey(p.from);
|
||||
toPk = new PublicKey(p.to);
|
||||
} catch {
|
||||
throw new Error('Invalid Solana address');
|
||||
}
|
||||
|
||||
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
|
||||
const tx = new Transaction({
|
||||
feePayer: fromPk,
|
||||
blockhash,
|
||||
lastValidBlockHeight,
|
||||
});
|
||||
|
||||
if (!p.token) {
|
||||
// Native SOL transfer
|
||||
tx.add(
|
||||
SystemProgram.transfer({
|
||||
fromPubkey: fromPk,
|
||||
toPubkey: toPk,
|
||||
lamports: BigInt(p.amount),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// SPL token transfer (manual instruction — не тянем @solana/spl-token)
|
||||
const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
|
||||
const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL');
|
||||
|
||||
const mint = solMintFor(p.token);
|
||||
if (!mint) throw new Error(`Unsupported SOL token: ${p.token}`);
|
||||
|
||||
const fromAta = await deriveAta(new PublicKey(mint), fromPk, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
|
||||
const toAta = await deriveAta(new PublicKey(mint), toPk, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
|
||||
|
||||
// Transfer instruction (instruction tag = 3 для SPL Token Transfer)
|
||||
const data = Buffer.alloc(9);
|
||||
data.writeUInt8(3, 0);
|
||||
data.writeBigUInt64LE(BigInt(p.amount), 1);
|
||||
|
||||
tx.add({
|
||||
programId: TOKEN_PROGRAM_ID,
|
||||
keys: [
|
||||
{ pubkey: fromAta, isSigner: false, isWritable: true },
|
||||
{ pubkey: toAta, isSigner: false, isWritable: true },
|
||||
{ pubkey: fromPk, isSigner: true, isWritable: false },
|
||||
],
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// Сериализуем сообщение (без подписей) для клиента
|
||||
const serialized = tx.serialize({ requireAllSignatures: false, verifySignatures: false });
|
||||
|
||||
return {
|
||||
kind: 'solana',
|
||||
instructions: serialized.toString('base64'),
|
||||
recentBlockhash: blockhash,
|
||||
};
|
||||
}
|
||||
|
||||
function solMintFor(symbol: string): string | null {
|
||||
const map: Record<string, string> = {
|
||||
USDT: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
|
||||
USDC: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
|
||||
};
|
||||
return map[symbol] ?? null;
|
||||
}
|
||||
|
||||
async function deriveAta(
|
||||
mint: any,
|
||||
owner: any,
|
||||
tokenProgramId: any,
|
||||
associatedTokenProgramId: any,
|
||||
): Promise<any> {
|
||||
const { PublicKey } = await import('@solana/web3.js');
|
||||
const [pda] = await PublicKey.findProgramAddress(
|
||||
[owner.toBuffer(), tokenProgramId.toBuffer(), mint.toBuffer()],
|
||||
associatedTokenProgramId,
|
||||
);
|
||||
return pda;
|
||||
}
|
||||
|
||||
// ─────────────────────── HELPERS ───────────────────────
|
||||
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
function tronAddressToHex(address: string): string {
|
||||
let num = 0n;
|
||||
for (const ch of address) {
|
||||
const i = BASE58_ALPHABET.indexOf(ch);
|
||||
if (i === -1) throw new Error('Invalid base58 character in TRON address');
|
||||
num = num * 58n + BigInt(i);
|
||||
}
|
||||
const hex = num.toString(16).padStart(50, '0');
|
||||
return hex.slice(2, 42); // 20 bytes без префикса 0x41
|
||||
}
|
||||
|
||||
function hexToTron(hex: string): string {
|
||||
// Формат TronGrid: 41xxxx (20 bytes after prefix). Это base58check.
|
||||
// Для простоты возвращаем как есть hex (можно улучшить на full base58check если нужно).
|
||||
return hex;
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: controller.signal });
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Upstream ${res.status}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
return await res.json();
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, msg: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error(msg)), ms);
|
||||
promise.then(
|
||||
(v) => { clearTimeout(t); resolve(v); },
|
||||
(e) => { clearTimeout(t); reject(e); },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -2,19 +2,28 @@
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "CryptoWallet API",
|
||||
"version": "2.0.0",
|
||||
"description": "Multi-chain cryptocurrency wallet API with blockchain proxy services"
|
||||
"version": "2.1.0",
|
||||
"description": "Multi-chain crypto wallet API. Auth via JWT (cookie/Bearer), issued by external auth-service (BITOK). Non-custodial: server NEVER signs transactions, только строит unsigned tx + хранит зашифрованный vault."
|
||||
},
|
||||
"servers": [
|
||||
{ "url": "/api", "description": "API" }
|
||||
{ "url": "/api", "description": "API root" }
|
||||
],
|
||||
"tags": [
|
||||
{ "name": "System", "description": "Health & service info" },
|
||||
{ "name": "Wallets", "description": "User wallet records" },
|
||||
{ "name": "Wallet Ops", "description": "Per-chain balance / transactions / send" },
|
||||
{ "name": "Vault", "description": "Encrypted mnemonic blob storage (opaque)" },
|
||||
{ "name": "BTC", "description": "Bitcoin RPC proxy (Blockstream)" },
|
||||
{ "name": "TRON", "description": "TRON RPC proxy (TronGrid)" },
|
||||
{ "name": "Solana", "description": "Solana swap proxy (Jupiter)" },
|
||||
{ "name": "TRON Swap", "description": "TRON swap proxy (SunSwap + FeeSwapRouter)" },
|
||||
{ "name": "BSC", "description": "BSC swap proxy (PancakeSwap V2)" },
|
||||
{ "name": "Relay", "description": "Cross-chain bridges (Relay Protocol)" }
|
||||
],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
"bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" },
|
||||
"cookieAuth": { "type": "apiKey", "in": "cookie", "name": "access_token" }
|
||||
},
|
||||
"schemas": {
|
||||
"Error": {
|
||||
@@ -24,78 +33,388 @@
|
||||
"error": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"Wallet": {
|
||||
"SuccessEmpty": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chain": { "type": "string", "enum": ["ETH", "BTC", "SOL", "TRX", "BSC"] },
|
||||
"address": { "type": "string" },
|
||||
"derivationPath": { "type": "string" }
|
||||
}
|
||||
"properties": { "success": { "type": "boolean", "example": true } }
|
||||
},
|
||||
"HealthResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean", "example": true },
|
||||
"data": {
|
||||
"data": { "type": "object", "properties": { "status": { "type": "string", "example": "ok" } } }
|
||||
}
|
||||
},
|
||||
"Chain": {
|
||||
"type": "string",
|
||||
"enum": ["ETH", "BTC", "SOL", "TRX", "BSC"]
|
||||
},
|
||||
"Wallet": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "example": "ok" }
|
||||
}
|
||||
"chain": { "$ref": "#/components/schemas/Chain" },
|
||||
"address": { "type": "string" },
|
||||
"derivationPath": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"WalletInput": {
|
||||
"type": "object",
|
||||
"required": ["chain", "address", "derivationPath"],
|
||||
"properties": {
|
||||
"chain": { "$ref": "#/components/schemas/Chain" },
|
||||
"address": { "type": "string", "maxLength": 256 },
|
||||
"derivationPath": { "type": "string", "maxLength": 64 }
|
||||
}
|
||||
},
|
||||
"CreateWalletsRequest": {
|
||||
"type": "object",
|
||||
"required": ["wallets"],
|
||||
"properties": {
|
||||
"wallets": {
|
||||
"type": "array", "minItems": 1, "maxItems": 20,
|
||||
"items": { "$ref": "#/components/schemas/WalletInput" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"summary": "Health check",
|
||||
"tags": ["System"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Service is healthy",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HealthResponse" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"WalletsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean", "example": true },
|
||||
"data": { "type": "array", "items": { "$ref": "#/components/schemas/Wallet" } }
|
||||
}
|
||||
},
|
||||
"/wallets": {
|
||||
"get": {
|
||||
"summary": "Get user wallets",
|
||||
"tags": ["Wallets"],
|
||||
"security": [{ "bearerAuth": [] }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of wallets",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"BalanceResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean", "example": true },
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/Wallet" }
|
||||
}
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chain": { "$ref": "#/components/schemas/Chain" },
|
||||
"address": { "type": "string" },
|
||||
"native": { "type": "string", "description": "Balance в smallest units (sat/wei/lamports/sun)" },
|
||||
"tokens": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"example": { "USDT": "12345678" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/Error" }
|
||||
"Transaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"txid": { "type": "string" },
|
||||
"timestamp": { "type": "integer", "nullable": true, "description": "Unix seconds" },
|
||||
"direction": { "type": "string", "enum": ["in", "out", "self"] },
|
||||
"amount": { "type": "string", "nullable": true },
|
||||
"token": { "type": "string", "nullable": true },
|
||||
"from": { "type": "string", "nullable": true },
|
||||
"to": { "type": "string", "nullable": true }
|
||||
}
|
||||
},
|
||||
"TransactionsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" },
|
||||
"data": { "type": "array", "items": { "$ref": "#/components/schemas/Transaction" } }
|
||||
}
|
||||
},
|
||||
"SendRequest": {
|
||||
"type": "object",
|
||||
"required": ["to", "amount"],
|
||||
"properties": {
|
||||
"to": { "type": "string", "description": "Recipient address" },
|
||||
"amount": { "type": "string", "description": "Amount в smallest units" },
|
||||
"token": { "type": "string", "nullable": true, "description": "Например USDT для TRC20/ERC20/BEP20. Без token = native (TRX/ETH/BNB/BTC)" }
|
||||
}
|
||||
},
|
||||
"UnsignedTxResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" },
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Unsigned tx — формат зависит от chain (kind: btc | tron | evm | solana). Клиент подписывает приватом и broadcast'ит через соответствующий /api/{btc,tron}/broadcast endpoint"
|
||||
}
|
||||
}
|
||||
},
|
||||
"VaultResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" },
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"encryptedVault": { "type": "string", "description": "AES-GCM encrypted mnemonic, base64" },
|
||||
"vaultSalt": { "type": "string", "description": "PBKDF2 salt, hex" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"VaultPutRequest": {
|
||||
"type": "object",
|
||||
"required": ["encryptedVault", "vaultSalt"],
|
||||
"properties": {
|
||||
"encryptedVault": { "type": "string", "maxLength": 8192 },
|
||||
"vaultSalt": { "type": "string", "maxLength": 128 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{ "cookieAuth": [] },
|
||||
{ "bearerAuth": [] }
|
||||
],
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"summary": "Liveness check",
|
||||
"tags": ["System"],
|
||||
"security": [],
|
||||
"responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HealthResponse" } } } } }
|
||||
}
|
||||
},
|
||||
|
||||
"/wallets": {
|
||||
"get": {
|
||||
"summary": "Get all wallets of authenticated user",
|
||||
"tags": ["Wallets"],
|
||||
"responses": {
|
||||
"200": { "description": "List of wallets", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WalletsResponse" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Upsert wallets for authenticated user",
|
||||
"description": "user_id берётся из JWT (sub). При первом обращении создаёт user-row автоматически. На конфликт (user_id, chain) — обновляет address + derivationPath.",
|
||||
"tags": ["Wallets"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateWalletsRequest" } } }
|
||||
},
|
||||
"responses": {
|
||||
"201": { "description": "Created/updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WalletsResponse" } } } },
|
||||
"400": { "description": "Invalid input", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"/wallets/{chain}/balance": {
|
||||
"get": {
|
||||
"summary": "Balance for user wallet in chain",
|
||||
"tags": ["Wallet Ops"],
|
||||
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }],
|
||||
"responses": {
|
||||
"200": { "description": "Balance", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BalanceResponse" } } } },
|
||||
"404": { "description": "Wallet for this chain not found" },
|
||||
"502": { "description": "Upstream RPC error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"/wallets/{chain}/transactions": {
|
||||
"get": {
|
||||
"summary": "Transaction history for user wallet in chain",
|
||||
"tags": ["Wallet Ops"],
|
||||
"parameters": [
|
||||
{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } },
|
||||
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 } }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "List of transactions", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TransactionsResponse" } } } },
|
||||
"404": { "description": "Wallet for this chain not found" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"/wallets/{chain}/send": {
|
||||
"post": {
|
||||
"summary": "Build unsigned send transaction (non-custodial)",
|
||||
"description": "Возвращает unsigned tx. Клиент подписывает приватным ключом и broadcast'ит через /api/{btc,tron}/broadcast или RPC своей цепи.",
|
||||
"tags": ["Wallet Ops"],
|
||||
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendRequest" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Unsigned tx", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnsignedTxResponse" } } } },
|
||||
"400": { "description": "Invalid input" },
|
||||
"404": { "description": "Wallet not found" },
|
||||
"502": { "description": "Upstream RPC error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"/vault": {
|
||||
"get": {
|
||||
"summary": "Get user's encrypted mnemonic vault",
|
||||
"tags": ["Vault"],
|
||||
"responses": {
|
||||
"200": { "description": "Encrypted vault blob", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/VaultResponse" } } } },
|
||||
"404": { "description": "Vault not yet stored" }
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"summary": "Save / replace encrypted mnemonic vault",
|
||||
"description": "Vault — opaque blob (AES-GCM на стороне клиента). Сервер хранит как есть, не расшифровывает.",
|
||||
"tags": ["Vault"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/VaultPutRequest" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Saved", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessEmpty" } } } },
|
||||
"400": { "description": "Invalid input" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"/btc/utxos/{address}": {
|
||||
"get": {
|
||||
"summary": "Confirmed UTXOs for Bitcoin address",
|
||||
"tags": ["BTC"],
|
||||
"parameters": [{ "name": "address", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||
"responses": { "200": { "description": "UTXOs" }, "401": { "description": "Not authenticated" } }
|
||||
}
|
||||
},
|
||||
"/btc/fee-estimates": {
|
||||
"get": {
|
||||
"summary": "Bitcoin fee estimates (sat/vB)",
|
||||
"tags": ["BTC"],
|
||||
"responses": { "200": { "description": "fast/normal/slow" }, "401": { "description": "Not authenticated" } }
|
||||
}
|
||||
},
|
||||
"/btc/broadcast": {
|
||||
"post": {
|
||||
"summary": "Broadcast raw signed Bitcoin tx",
|
||||
"tags": ["BTC"],
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["hex"], "properties": { "hex": { "type": "string" } } } } } },
|
||||
"responses": { "200": { "description": "txid" }, "400": { "description": "Invalid hex" } }
|
||||
}
|
||||
},
|
||||
|
||||
"/tron/account/{address}": {
|
||||
"get": {
|
||||
"summary": "TRON account info + USDT (TRC20) balance",
|
||||
"tags": ["TRON"],
|
||||
"parameters": [{ "name": "address", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||
"responses": { "200": { "description": "Account data" } }
|
||||
}
|
||||
},
|
||||
"/tron/createtransaction": {
|
||||
"post": {
|
||||
"summary": "Build unsigned TRX transfer",
|
||||
"tags": ["TRON"],
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["owner_address", "to_address", "amount"], "properties": { "owner_address": { "type": "string" }, "to_address": { "type": "string" }, "amount": { "type": "integer" } } } } } },
|
||||
"responses": { "200": { "description": "Unsigned tx" } }
|
||||
}
|
||||
},
|
||||
"/tron/triggersmartcontract": {
|
||||
"post": {
|
||||
"summary": "Build unsigned TRC20 contract call",
|
||||
"tags": ["TRON"],
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
|
||||
"responses": { "200": { "description": "Unsigned tx" } }
|
||||
}
|
||||
},
|
||||
"/tron/broadcasttransaction": {
|
||||
"post": {
|
||||
"summary": "Broadcast signed TRON tx",
|
||||
"tags": ["TRON"],
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
|
||||
"responses": { "200": { "description": "Result" } }
|
||||
}
|
||||
},
|
||||
|
||||
"/sol/swap/quote": {
|
||||
"get": {
|
||||
"summary": "Jupiter swap quote (Solana)",
|
||||
"tags": ["Solana"],
|
||||
"parameters": [
|
||||
{ "name": "inputMint", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "outputMint", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "amount", "in": "query", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "slippageBps", "in": "query", "required": true, "schema": { "type": "integer" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Quote" } }
|
||||
}
|
||||
},
|
||||
"/sol/swap/build": {
|
||||
"post": {
|
||||
"summary": "Jupiter swap build",
|
||||
"tags": ["Solana"],
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["quoteResponse", "userPublicKey"], "properties": { "quoteResponse": { "type": "object" }, "userPublicKey": { "type": "string" } } } } } },
|
||||
"responses": { "200": { "description": "Swap tx" } }
|
||||
}
|
||||
},
|
||||
|
||||
"/tron/swap/quote": {
|
||||
"get": {
|
||||
"summary": "TRON swap quote (TRX <-> USDT)",
|
||||
"tags": ["TRON Swap"],
|
||||
"parameters": [
|
||||
{ "name": "from", "in": "query", "required": true, "schema": { "type": "string", "enum": ["TRX", "USDT"] } },
|
||||
{ "name": "to", "in": "query", "required": true, "schema": { "type": "string", "enum": ["TRX", "USDT"] } },
|
||||
{ "name": "amount", "in": "query", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Quote" } }
|
||||
}
|
||||
},
|
||||
"/tron/swap/build": {
|
||||
"post": {
|
||||
"summary": "Build TRON swap transactions",
|
||||
"tags": ["TRON Swap"],
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["from", "to", "amount", "amountOutMin", "userAddress"], "properties": { "from": { "type": "string" }, "to": { "type": "string" }, "amount": { "type": "string" }, "amountOutMin": { "type": "string" }, "userAddress": { "type": "string" } } } } } },
|
||||
"responses": { "200": { "description": "Unsigned txs" } }
|
||||
}
|
||||
},
|
||||
"/tron/swap/broadcast": {
|
||||
"post": {
|
||||
"summary": "Broadcast signed TRON swap",
|
||||
"tags": ["TRON Swap"],
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["signedTransaction"], "properties": { "signedTransaction": { "type": "object" } } } } } },
|
||||
"responses": { "200": { "description": "Result" } }
|
||||
}
|
||||
},
|
||||
|
||||
"/bsc/swap/quote": {
|
||||
"get": {
|
||||
"summary": "BSC swap quote (PancakeSwap V2)",
|
||||
"tags": ["BSC"],
|
||||
"parameters": [
|
||||
{ "name": "from", "in": "query", "required": true, "schema": { "type": "string", "enum": ["BNB", "USDT", "DOGE"] } },
|
||||
{ "name": "to", "in": "query", "required": true, "schema": { "type": "string", "enum": ["BNB", "USDT", "DOGE"] } },
|
||||
{ "name": "amount", "in": "query", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Quote" } }
|
||||
}
|
||||
},
|
||||
"/bsc/swap/build": {
|
||||
"post": {
|
||||
"summary": "Build BSC swap transactions",
|
||||
"tags": ["BSC"],
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["from", "to", "amount", "amountOutMin", "userAddress"], "properties": { "from": { "type": "string" }, "to": { "type": "string" }, "amount": { "type": "string" }, "amountOutMin": { "type": "string" }, "userAddress": { "type": "string" } } } } } },
|
||||
"responses": { "200": { "description": "Unsigned txs" } }
|
||||
}
|
||||
},
|
||||
|
||||
"/relay/quote/v2": {
|
||||
"get": { "summary": "Relay bridge quote", "tags": ["Relay"], "responses": { "200": { "description": "Quote" } } }
|
||||
},
|
||||
"/relay/intents/status/v3": {
|
||||
"get": { "summary": "Relay intent status", "tags": ["Relay"], "responses": { "200": { "description": "Status" } } }
|
||||
},
|
||||
"/relay/execute/{action}": {
|
||||
"post": {
|
||||
"summary": "Relay execute",
|
||||
"tags": ["Relay"],
|
||||
"parameters": [{ "name": "action", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
|
||||
"responses": { "200": { "description": "Result" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: cryptowallet-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-cryptowallet_v2}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
# Наружу НЕ экспозим — только docker network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-cryptowallet_v2}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: cryptowallet-api:latest
|
||||
container_name: cryptowallet-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3001"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Override внутри docker network
|
||||
DB_HOST: postgres
|
||||
API_PORT: "3001"
|
||||
NODE_ENV: production
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
driver: local
|
||||
447
pnpm-lock.yaml
generated
447
pnpm-lock.yaml
generated
@@ -17,6 +17,12 @@ importers:
|
||||
|
||||
apps/api:
|
||||
dependencies:
|
||||
'@solana/web3.js':
|
||||
specifier: ^1.98.4
|
||||
version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)
|
||||
bs58:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
cookie-parser:
|
||||
specifier: ^1.4.7
|
||||
version: 1.4.7
|
||||
@@ -28,10 +34,13 @@ importers:
|
||||
version: 16.6.1
|
||||
ethers:
|
||||
specifier: 5.7.2
|
||||
version: 5.7.2(bufferutil@4.1.0)
|
||||
version: 5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)
|
||||
express:
|
||||
specifier: ^4.21.0
|
||||
version: 4.22.1
|
||||
express-rate-limit:
|
||||
specifier: ^8.4.1
|
||||
version: 8.4.1(express@4.22.1)
|
||||
helmet:
|
||||
specifier: ^8.0.0
|
||||
version: 8.1.0
|
||||
@@ -90,6 +99,10 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/runtime@7.29.2':
|
||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -300,6 +313,14 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@noble/curves@1.9.7':
|
||||
resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@noble/hashes@1.8.0':
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -315,6 +336,35 @@ packages:
|
||||
'@scarf/scarf@1.4.0':
|
||||
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
|
||||
|
||||
'@solana/buffer-layout@4.0.1':
|
||||
resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==}
|
||||
engines: {node: '>=5.10'}
|
||||
|
||||
'@solana/codecs-core@2.3.0':
|
||||
resolution: {integrity: sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==}
|
||||
engines: {node: '>=20.18.0'}
|
||||
peerDependencies:
|
||||
typescript: '>=5.3.3'
|
||||
|
||||
'@solana/codecs-numbers@2.3.0':
|
||||
resolution: {integrity: sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==}
|
||||
engines: {node: '>=20.18.0'}
|
||||
peerDependencies:
|
||||
typescript: '>=5.3.3'
|
||||
|
||||
'@solana/errors@2.3.0':
|
||||
resolution: {integrity: sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==}
|
||||
engines: {node: '>=20.18.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '>=5.3.3'
|
||||
|
||||
'@solana/web3.js@1.98.4':
|
||||
resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==}
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
|
||||
|
||||
'@tsconfig/node10@1.0.12':
|
||||
resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==}
|
||||
|
||||
@@ -350,6 +400,9 @@ packages:
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
'@types/node@12.20.55':
|
||||
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
|
||||
|
||||
'@types/node@20.19.37':
|
||||
resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==}
|
||||
|
||||
@@ -374,6 +427,15 @@ packages:
|
||||
'@types/swagger-ui-express@4.1.8':
|
||||
resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==}
|
||||
|
||||
'@types/uuid@10.0.0':
|
||||
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
|
||||
|
||||
'@types/ws@7.4.7':
|
||||
resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@7.18.0':
|
||||
resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==}
|
||||
engines: {node: ^18.18.0 || >=20.0.0}
|
||||
@@ -456,6 +518,10 @@ packages:
|
||||
aes-js@3.0.0:
|
||||
resolution: {integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==}
|
||||
|
||||
agentkeepalive@4.6.0:
|
||||
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
|
||||
engines: {node: '>= 8.0.0'}
|
||||
|
||||
ajv@6.14.0:
|
||||
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
||||
|
||||
@@ -487,6 +553,15 @@ packages:
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
base-x@3.0.11:
|
||||
resolution: {integrity: sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==}
|
||||
|
||||
base-x@5.0.1:
|
||||
resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
bech32@1.1.4:
|
||||
resolution: {integrity: sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==}
|
||||
|
||||
@@ -504,6 +579,9 @@ packages:
|
||||
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
|
||||
borsh@0.7.0:
|
||||
resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
|
||||
@@ -517,9 +595,18 @@ packages:
|
||||
brorand@1.1.0:
|
||||
resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==}
|
||||
|
||||
bs58@4.0.1:
|
||||
resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==}
|
||||
|
||||
bs58@6.0.0:
|
||||
resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
buffer@6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
||||
bufferutil@4.1.0:
|
||||
resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==}
|
||||
engines: {node: '>=6.14.2'}
|
||||
@@ -544,6 +631,10 @@ packages:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
chalk@5.6.2:
|
||||
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
|
||||
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
||||
|
||||
chokidar@3.6.0:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
@@ -562,6 +653,13 @@ packages:
|
||||
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
commander@14.0.3:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
@@ -618,6 +716,10 @@ packages:
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
delay@5.0.0:
|
||||
resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -674,6 +776,12 @@ packages:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es6-promise@4.2.8:
|
||||
resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==}
|
||||
|
||||
es6-promisify@5.0.0:
|
||||
resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==}
|
||||
|
||||
escalade@3.2.0:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -730,10 +838,23 @@ packages:
|
||||
ethers@5.7.2:
|
||||
resolution: {integrity: sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
express-rate-limit@8.4.1:
|
||||
resolution: {integrity: sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
express: '>= 4.11'
|
||||
|
||||
express@4.22.1:
|
||||
resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
|
||||
eyes@0.1.8:
|
||||
resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==}
|
||||
engines: {node: '> 0.1.90'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -747,6 +868,9 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-stable-stringify@1.0.0:
|
||||
resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==}
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
@@ -860,10 +984,16 @@ packages:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
humanize-ms@1.2.1:
|
||||
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -887,6 +1017,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
ip-address@10.1.0:
|
||||
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -918,6 +1052,16 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
isomorphic-ws@4.0.1:
|
||||
resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==}
|
||||
peerDependencies:
|
||||
ws: '*'
|
||||
|
||||
jayson@4.3.0:
|
||||
resolution: {integrity: sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.2:
|
||||
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
||||
|
||||
@@ -937,6 +1081,9 @@ packages:
|
||||
json-stable-stringify-without-jsonify@1.0.1:
|
||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||
|
||||
json-stringify-safe@5.0.1:
|
||||
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -1061,6 +1208,15 @@ packages:
|
||||
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
peerDependencies:
|
||||
encoding: ^0.1.0
|
||||
peerDependenciesMeta:
|
||||
encoding:
|
||||
optional: true
|
||||
|
||||
node-gyp-build@4.8.4:
|
||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||
hasBin: true
|
||||
@@ -1245,6 +1401,9 @@ packages:
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
rpc-websockets@9.3.8:
|
||||
resolution: {integrity: sha512-7r+fm4tSJmLf9GvZfL1DJ1SJwpagpp6AazqM0FUaeV7CA+7+NYINSk1syWa4tU/6OF2CyBicLtzENGmXRJH6wQ==}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
@@ -1316,6 +1475,12 @@ packages:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
stream-chain@2.2.5:
|
||||
resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==}
|
||||
|
||||
stream-json@1.9.1:
|
||||
resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1332,6 +1497,10 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
superstruct@2.0.2:
|
||||
resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1353,6 +1522,9 @@ packages:
|
||||
resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
text-encoding-utf-8@1.0.2:
|
||||
resolution: {integrity: sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==}
|
||||
|
||||
text-table@0.2.0:
|
||||
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
||||
|
||||
@@ -1368,6 +1540,9 @@ packages:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
tree-kill@1.2.2:
|
||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||
hasBin: true
|
||||
@@ -1406,6 +1581,9 @@ packages:
|
||||
tsconfig@7.0.0:
|
||||
resolution: {integrity: sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
turbo-darwin-64@2.8.15:
|
||||
resolution: {integrity: sha512-EElCh+Ltxex9lXYrouV3hHjKP3HFP31G91KMghpNHR/V99CkFudRcHcnWaorPbzAZizH1m8o2JkLL8rptgb8WQ==}
|
||||
cpu: [x64]
|
||||
@@ -1471,10 +1649,23 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
utf-8-validate@6.0.6:
|
||||
resolution: {integrity: sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==}
|
||||
engines: {node: '>=6.14.2'}
|
||||
|
||||
utils-merge@1.0.1:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
uuid@11.1.1:
|
||||
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
|
||||
hasBin: true
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
v8-compile-cache-lib@3.0.1:
|
||||
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
||||
|
||||
@@ -1482,6 +1673,12 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1506,6 +1703,30 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@7.5.10:
|
||||
resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==}
|
||||
engines: {node: '>=8.3.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: ^5.0.2
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.20.0:
|
||||
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
@@ -1520,6 +1741,8 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/runtime@7.29.2': {}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
@@ -1808,7 +2031,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@ethersproject/logger': 5.8.0
|
||||
|
||||
'@ethersproject/providers@5.7.2(bufferutil@4.1.0)':
|
||||
'@ethersproject/providers@5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)':
|
||||
dependencies:
|
||||
'@ethersproject/abstract-provider': 5.8.0
|
||||
'@ethersproject/abstract-signer': 5.8.0
|
||||
@@ -1829,7 +2052,7 @@ snapshots:
|
||||
'@ethersproject/transactions': 5.8.0
|
||||
'@ethersproject/web': 5.8.0
|
||||
bech32: 1.1.4
|
||||
ws: 7.4.6(bufferutil@4.1.0)
|
||||
ws: 7.4.6(bufferutil@4.1.0)(utf-8-validate@6.0.6)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
@@ -2006,6 +2229,12 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@noble/curves@1.9.7':
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -2020,6 +2249,54 @@ snapshots:
|
||||
|
||||
'@scarf/scarf@1.4.0': {}
|
||||
|
||||
'@solana/buffer-layout@4.0.1':
|
||||
dependencies:
|
||||
buffer: 6.0.3
|
||||
|
||||
'@solana/codecs-core@2.3.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@solana/errors': 2.3.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
||||
'@solana/codecs-numbers@2.3.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@solana/codecs-core': 2.3.0(typescript@5.9.3)
|
||||
'@solana/errors': 2.3.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
||||
'@solana/errors@2.3.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
commander: 14.0.3
|
||||
typescript: 5.9.3
|
||||
|
||||
'@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@noble/curves': 1.9.7
|
||||
'@noble/hashes': 1.8.0
|
||||
'@solana/buffer-layout': 4.0.1
|
||||
'@solana/codecs-numbers': 2.3.0(typescript@5.9.3)
|
||||
agentkeepalive: 4.6.0
|
||||
bn.js: 5.2.3
|
||||
borsh: 0.7.0
|
||||
bs58: 4.0.1
|
||||
buffer: 6.0.3
|
||||
fast-stable-stringify: 1.0.0
|
||||
jayson: 4.3.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
|
||||
node-fetch: 2.7.0
|
||||
rpc-websockets: 9.3.8
|
||||
superstruct: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- encoding
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tsconfig/node10@1.0.12': {}
|
||||
|
||||
'@tsconfig/node12@1.0.11': {}
|
||||
@@ -2060,6 +2337,8 @@ snapshots:
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/node@12.20.55': {}
|
||||
|
||||
'@types/node@20.19.37':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -2086,6 +2365,16 @@ snapshots:
|
||||
'@types/express': 5.0.6
|
||||
'@types/serve-static': 2.2.0
|
||||
|
||||
'@types/uuid@10.0.0': {}
|
||||
|
||||
'@types/ws@7.4.7':
|
||||
dependencies:
|
||||
'@types/node': 20.19.37
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 20.19.37
|
||||
|
||||
'@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
@@ -2186,6 +2475,10 @@ snapshots:
|
||||
|
||||
aes-js@3.0.0: {}
|
||||
|
||||
agentkeepalive@4.6.0:
|
||||
dependencies:
|
||||
humanize-ms: 1.2.1
|
||||
|
||||
ajv@6.14.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
@@ -2214,6 +2507,14 @@ snapshots:
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
base-x@3.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
base-x@5.0.1: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
bech32@1.1.4: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
@@ -2239,6 +2540,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
borsh@0.7.0:
|
||||
dependencies:
|
||||
bn.js: 5.2.3
|
||||
bs58: 4.0.1
|
||||
text-encoding-utf-8: 1.0.2
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
@@ -2254,8 +2561,21 @@ snapshots:
|
||||
|
||||
brorand@1.1.0: {}
|
||||
|
||||
bs58@4.0.1:
|
||||
dependencies:
|
||||
base-x: 3.0.11
|
||||
|
||||
bs58@6.0.0:
|
||||
dependencies:
|
||||
base-x: 5.0.1
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@6.0.3:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
bufferutil@4.1.0:
|
||||
dependencies:
|
||||
node-gyp-build: 4.8.4
|
||||
@@ -2280,6 +2600,8 @@ snapshots:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
chalk@5.6.2: {}
|
||||
|
||||
chokidar@3.6.0:
|
||||
dependencies:
|
||||
anymatch: 3.1.3
|
||||
@@ -2302,6 +2624,10 @@ snapshots:
|
||||
|
||||
commander@10.0.1: {}
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
content-disposition@0.5.4:
|
||||
@@ -2344,6 +2670,8 @@ snapshots:
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
delay@5.0.0: {}
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
destroy@1.2.0: {}
|
||||
@@ -2402,6 +2730,12 @@ snapshots:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
es6-promise@4.2.8: {}
|
||||
|
||||
es6-promisify@5.0.0:
|
||||
dependencies:
|
||||
es6-promise: 4.2.8
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
@@ -2480,7 +2814,7 @@ snapshots:
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
ethers@5.7.2(bufferutil@4.1.0):
|
||||
ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6):
|
||||
dependencies:
|
||||
'@ethersproject/abi': 5.7.0
|
||||
'@ethersproject/abstract-provider': 5.7.0
|
||||
@@ -2500,7 +2834,7 @@ snapshots:
|
||||
'@ethersproject/networks': 5.7.1
|
||||
'@ethersproject/pbkdf2': 5.7.0
|
||||
'@ethersproject/properties': 5.7.0
|
||||
'@ethersproject/providers': 5.7.2(bufferutil@4.1.0)
|
||||
'@ethersproject/providers': 5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)
|
||||
'@ethersproject/random': 5.7.0
|
||||
'@ethersproject/rlp': 5.7.0
|
||||
'@ethersproject/sha2': 5.7.0
|
||||
@@ -2516,6 +2850,13 @@ snapshots:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
express-rate-limit@8.4.1(express@4.22.1):
|
||||
dependencies:
|
||||
express: 4.22.1
|
||||
ip-address: 10.1.0
|
||||
|
||||
express@4.22.1:
|
||||
dependencies:
|
||||
accepts: 1.3.8
|
||||
@@ -2552,6 +2893,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eyes@0.1.8: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
@@ -2566,6 +2909,8 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-stable-stringify@1.0.0: {}
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
@@ -2699,10 +3044,16 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
humanize-ms@1.2.1:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
@@ -2721,6 +3072,8 @@ snapshots:
|
||||
|
||||
interpret@2.2.0: {}
|
||||
|
||||
ip-address@10.1.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
@@ -2743,6 +3096,28 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)):
|
||||
dependencies:
|
||||
ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)
|
||||
|
||||
jayson@4.3.0(bufferutil@4.1.0)(utf-8-validate@6.0.6):
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 12.20.55
|
||||
'@types/ws': 7.4.7
|
||||
commander: 2.20.3
|
||||
delay: 5.0.0
|
||||
es6-promisify: 5.0.0
|
||||
eyes: 0.1.8
|
||||
isomorphic-ws: 4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6))
|
||||
json-stringify-safe: 5.0.1
|
||||
stream-json: 1.9.1
|
||||
uuid: 8.3.2
|
||||
ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
jose@6.2.2: {}
|
||||
|
||||
js-sha3@0.8.0: {}
|
||||
@@ -2757,6 +3132,8 @@ snapshots:
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
||||
json-stringify-safe@5.0.1: {}
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -2848,6 +3225,10 @@ snapshots:
|
||||
|
||||
negotiator@0.6.3: {}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
node-gyp-build@4.8.4:
|
||||
optional: true
|
||||
|
||||
@@ -3001,6 +3382,19 @@ snapshots:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
rpc-websockets@9.3.8:
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.21
|
||||
'@types/uuid': 10.0.0
|
||||
'@types/ws': 8.18.1
|
||||
buffer: 6.0.3
|
||||
eventemitter3: 5.0.4
|
||||
uuid: 11.1.1
|
||||
ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
|
||||
optionalDependencies:
|
||||
bufferutil: 4.1.0
|
||||
utf-8-validate: 6.0.6
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
@@ -3089,6 +3483,12 @@ snapshots:
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
stream-chain@2.2.5: {}
|
||||
|
||||
stream-json@1.9.1:
|
||||
dependencies:
|
||||
stream-chain: 2.2.5
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
@@ -3099,6 +3499,8 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
superstruct@2.0.2: {}
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
@@ -3116,6 +3518,8 @@ snapshots:
|
||||
|
||||
tarn@3.0.2: {}
|
||||
|
||||
text-encoding-utf-8@1.0.2: {}
|
||||
|
||||
text-table@0.2.0: {}
|
||||
|
||||
tildify@2.0.0: {}
|
||||
@@ -3126,6 +3530,8 @@ snapshots:
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
|
||||
ts-api-utils@1.4.3(typescript@5.9.3):
|
||||
@@ -3175,6 +3581,8 @@ snapshots:
|
||||
strip-bom: 3.0.0
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
turbo-darwin-64@2.8.15:
|
||||
optional: true
|
||||
|
||||
@@ -3227,12 +3635,28 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
utf-8-validate@6.0.6:
|
||||
dependencies:
|
||||
node-gyp-build: 4.8.4
|
||||
optional: true
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
uuid@11.1.1: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
v8-compile-cache-lib@3.0.1: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -3241,9 +3665,20 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@7.4.6(bufferutil@4.1.0):
|
||||
ws@7.4.6(bufferutil@4.1.0)(utf-8-validate@6.0.6):
|
||||
optionalDependencies:
|
||||
bufferutil: 4.1.0
|
||||
utf-8-validate: 6.0.6
|
||||
|
||||
ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6):
|
||||
optionalDependencies:
|
||||
bufferutil: 4.1.0
|
||||
utf-8-validate: 6.0.6
|
||||
|
||||
ws@8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6):
|
||||
optionalDependencies:
|
||||
bufferutil: 4.1.0
|
||||
utf-8-validate: 6.0.6
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
|
||||
54
start.sh
54
start.sh
@@ -1,54 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=========================================="
|
||||
echo " CryptoWallet API - Production Deploy"
|
||||
echo "=========================================="
|
||||
|
||||
# 1. Docker check
|
||||
command -v docker >/dev/null || { echo "[ERROR] Docker not installed"; exit 1; }
|
||||
docker compose version >/dev/null 2>&1 || { echo "[ERROR] docker compose plugin missing"; exit 1; }
|
||||
|
||||
# 2. .env check
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.example .env
|
||||
echo "[INFO] Created .env from .env.example"
|
||||
echo "[INFO] Fill in VAULT_ROLE_ID, VAULT_SECRET_ID and re-run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Build & start
|
||||
echo "[INFO] Building image..."
|
||||
docker compose build --pull api
|
||||
|
||||
echo "[INFO] Starting services..."
|
||||
docker compose up -d
|
||||
|
||||
# 4. Wait for health
|
||||
echo "[INFO] Waiting for API to be healthy..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://localhost:3001/api/health >/dev/null 2>&1; then
|
||||
echo "[OK] API healthy"
|
||||
break
|
||||
fi
|
||||
if [ "$i" = "60" ]; then
|
||||
echo "[ERROR] API did not become healthy in 120s"
|
||||
docker compose logs --tail=50 api
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Deploy complete"
|
||||
echo ""
|
||||
echo " API: http://localhost:3001"
|
||||
echo " Health: http://localhost:3001/api/health"
|
||||
echo " Docs: http://localhost:3001/api/docs"
|
||||
echo ""
|
||||
echo " Logs: docker compose logs -f api"
|
||||
echo " Stop: docker compose down"
|
||||
echo "=========================================="
|
||||
Reference in New Issue
Block a user