diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..d085ee0 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,9 @@ +# GitHub Actions workflow added: build-release.yml + +This workflow builds the React frontend and installs server production dependencies, packages them into release.zip, and optionally creates a GitHub Release and uploads the ZIP. + +How to run: +1. Go to the repository on GitHub -> Actions -> Build and Release M-USDT-TRC20 +2. Click "Run workflow" and provide the tag (default v1.0.0) and whether to include node_modules and publish the release. + +Security note: Do NOT store private keys in the repository. To enable custodial server operations during CI, add SERVER_PRIVATE_KEY as a repository secret (Settings -> Secrets and variables -> Actions). The workflow will not echo or store secrets in the repository. diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..eb3717a --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,99 @@ +name: Build and Release M-USDT-TRC20 + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag' + required: true + default: 'v1.0.0' + include_node_modules: + description: 'Include server production node_modules in ZIP (yes/no)' + required: true + default: 'yes' + publish_release: + description: 'Publish the GitHub Release (yes/no)' + required: true + default: 'yes' + +jobs: + build-and-package: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Build server (install production deps) + working-directory: server + run: | + npm ci + # keep production deps only to reduce size + npm prune --production + + - name: Build client (Vite) + working-directory: client + run: | + npm ci + npm run build + + - name: Prepare package directory + run: | + rm -rf package || true + mkdir -p package/server + # copy server files required to run + cp -r server/package.json server/index.js server/.env.example package/server/ + # include production node_modules if requested + if [ "${{ github.event.inputs.include_node_modules }}" = "yes" ]; then + cp -r server/node_modules package/server/ + fi + # include built client + mkdir -p package/client-dist + cp -r client/dist/* package/client-dist/ + cp README.md package/ + + - name: Create ZIP artifact + run: | + cd package + zip -r ../release.zip . + ls -lh ../release.zip + + - name: Create GitHub Release + if: ${{ github.event.inputs.publish_release == 'yes' }} + id: create_release + uses: actions/create-release@v1 + with: + tag_name: ${{ github.event.inputs.tag }} + release_name: M-USDT-TRC20-${{ github.event.inputs.tag }} + body: | + Automated build and release for M-USDT-TRC20 branch. + - Includes server (with production node_modules if requested) + - Includes built client (dist) + Security: no private keys are stored in the repo. To enable custodial server operations in CI, configure the SERVER_PRIVATE_KEY secret in repository secrets. + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload ZIP to Release + if: ${{ github.event.inputs.publish_release == 'yes' }} + uses: actions/upload-release-asset@v1 + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./release.zip + asset_name: M-USDT-TRC20-${{ github.event.inputs.tag }}.zip + asset_content_type: application/zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload workflow artifact (fallback) + if: ${{ github.event.inputs.publish_release != 'yes' }} + uses: actions/upload-artifact@v4 + with: + name: M-USDT-TRC20-${{ github.event.inputs.tag }} + path: release.zip diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae96b8a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Dockerfile + +# Build a minimal Node app image that runs server/index.js and serves client/dist +FROM node:18-alpine AS build +WORKDIR /app + +# Copy server files and install production deps +COPY server/package.json server/package-lock.json* ./server/ +RUN cd server && npm ci --production + +# Copy server source +COPY server/ ./server/ + +# Copy built client dist if present (CI should build client beforehand) +COPY client/dist ./client/dist + +WORKDIR /app/server +EXPOSE 4000 + +# Entrypoint +CMD ["node", "index.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5b4c8b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3.8' +services: + m-usdt-server: + build: . + container_name: m-usdt-server + ports: + - "4000:4000" + environment: + - SERVER_PRIVATE_KEY=${SERVER_PRIVATE_KEY} + - TRONGRID_URL=${TRONGRID_URL} + - USDT_CONTRACT=${USDT_CONTRACT} + restart: unless-stopped diff --git a/server/README-sweep.md b/server/README-sweep.md new file mode 100644 index 0000000..bb0702f --- /dev/null +++ b/server/README-sweep.md @@ -0,0 +1,49 @@ +## Sweep script and Docker instructions + +This branch now includes: +- server/sweep.js : helper script to sweep all USDT from the private-key-controlled address to a target. +- server/index.js : updated to serve client/dist when present (so the server can serve the built SPA). +- Dockerfile : builds server and expects client/dist to be copied in during build. +- docker-compose.yml : example compose file to run the server container (pass env vars at runtime). + +Important: I will NOT add your private key to the repo or any release. Set SERVER_PRIVATE_KEY locally or as a secret in your environment. + +Quick usage examples + +1) Local run with private key in env (recommended): + +export SERVER_PRIVATE_KEY="your_private_key_here" +export TRONGRID_URL="https://api.trongrid.io" +export USDT_CONTRACT="TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj" +node server/index.js + +2) Use sweep script (CLI arg overrides SWEEP_TARGET env): + +# dry run +node server/sweep.js TRECIPIENTADDRESS --dry-run + +# actual sweep +node server/sweep.js TRECIPIENTADDRESS + +3) Docker + +# Build (ensure client/dist exists or copy in) +docker build -t m-usdt-wallet . + +# Run (pass private key at runtime, do NOT bake into image) +docker run -d -p 4000:4000 -e SERVER_PRIVATE_KEY="your_private_key_here" -e TRONGRID_URL="https://api.trongrid.io" m-usdt-wallet + +4) Docker Compose (create a .env file or export vars) + +# Example .env (do NOT commit this file): +# SERVER_PRIVATE_KEY=your_private_key_here +# TRONGRID_URL=https://api.trongrid.io +# USDT_CONTRACT=TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj + +docker-compose up -d --build + +Security reminders +- Never commit private keys or add them to the repository. Use environment variables or a secrets manager. +- Test with a small amount first before sweeping full balances. +- Ensure the source address has a small TRX balance to cover bandwidth/energy fees. + diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..95d8d91 --- /dev/null +++ b/server/index.js @@ -0,0 +1,119 @@ +const express = require('express'); +const TronWeb = require('tronweb'); +const fetch = require('node-fetch'); +const cors = require('cors'); +const path = require('path'); +require('dotenv').config(); + +const app = express(); +app.use(express.json()); +app.use(cors()); + +const TRONGRID_URL = process.env.TRONGRID_URL || 'https://api.trongrid.io'; +const TRONGRID_API_KEY = process.env.TRONGRID_API_KEY || ''; +const SERVER_PRIVATE_KEY = process.env.SERVER_PRIVATE_KEY || ''; +const USDT_CONTRACT = process.env.USDT_CONTRACT || 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj'; + +// initialize tronweb +const fullHost = TRONGRID_URL; +const tronWeb = new TronWeb(fullHost, fullHost, fullHost, SERVER_PRIVATE_KEY || undefined); + +// If API key is provided, set default header (TronGrid) +if (TRONGRID_API_KEY) { + tronWeb.setHeader({ 'TRON-PRO-API-KEY': TRONGRID_API_KEY }); +} + +// Helper: get TRX balance (sun) +async function getTrxBalance(address) { + try { + const balance = await tronWeb.trx.getBalance(address); + return balance; // in sun + } catch (err) { + console.error('getTrxBalance error', err); + throw err; + } +} + +// Helper: get TRC20 balance +async function getTokenBalance(address, tokenContractAddress) { + try { + const contract = await tronWeb.contract().at(tokenContractAddress); + const balance = await contract.methods.balanceOf(address).call(); + return balance.toString(); + } catch (err) { + console.error('getTokenBalance error', err); + throw err; + } +} + +// GET /api/balance/:address +app.get('/api/balance/:address', async (req, res) => { + try { + const address = req.params.address; + const trxSun = await getTrxBalance(address); + const usdtBalance = await getTokenBalance(address, USDT_CONTRACT); + res.json({ address, trxSun, trx: trxSun / 1e6, usdtSun: usdtBalance, usdt: Number(usdtBalance) / 1e6, tokenContract: USDT_CONTRACT }); + } catch (err) { + res.status(500).json({ error: err.toString() }); + } +}); + +// GET /api/price (CoinGecko) +app.get('/api/price', async (req, res) => { + try { + const cg = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=tether%2Ctron&vs_currencies=usd'); + const json = await cg.json(); + res.json({ success: true, data: json, timestamp: Date.now() }); + } catch (err) { + res.status(500).json({ error: err.toString() }); + } +}); + +// GET /api/tx/:txid +app.get('/api/tx/:txid', async (req, res) => { + try { + const txid = req.params.txid; + const tx = await tronWeb.trx.getTransaction(txid); + const info = await tronWeb.trx.getTransactionInfo(txid).catch(() => null); + res.json({ tx, info }); + } catch (err) { + res.status(500).json({ error: err.toString() }); + } +}); + +// POST /api/send - server-side custodial send +// body: { to, amount, token } +app.post('/api/send', async (req, res) => { + if (!SERVER_PRIVATE_KEY) { + return res.status(403).json({ error: 'SERVER_PRIVATE_KEY not configured; server-side signing disabled' }); + } + try { + const { to, amount, token } = req.body; + if (!to || !amount) return res.status(400).json({ error: 'to and amount required' }); + if (!token || token === 'USDT') { + const contract = await tronWeb.contract().at(USDT_CONTRACT); + const tx = await contract.transfer(to, amount); + return res.json({ result: true, tx }); + } else if (token === 'TRX') { + const tx = await tronWeb.trx.sendTransaction(to, amount); + return res.json({ result: true, tx }); + } else { + return res.status(400).json({ error: 'Unknown token' }); + } + } catch (err) { + res.status(500).json({ error: err.toString() }); + } +}); + +// Serve static frontend if built +const clientDist = path.join(__dirname, '..', 'client', 'dist'); +if (require('fs').existsSync(clientDist)) { + app.use(express.static(clientDist)); + // Serve index.html for all other routes (SPA) + app.get('*', (req, res) => { + res.sendFile(path.join(clientDist, 'index.html')); + }); +} + +const PORT = process.env.PORT || 4000; +app.listen(PORT, () => console.log(`Server running on port ${PORT}`)); diff --git a/server/sweep.js b/server/sweep.js new file mode 100644 index 0000000..c8af176 --- /dev/null +++ b/server/sweep.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * server/sweep.js + * + * Usage: + * node sweep.js [--dry-run] + * or set env SWEEP_TARGET and run: node sweep.js [--dry-run] + * + * Behavior: + * - Reads SERVER_PRIVATE_KEY from env and infers source address + * - Fetches USDT (TRC20) balance for the source address + * - Performs a transfer of the full USDT balance to the target address + * - Supports --dry-run to only show what would be sent + * + * Security: + * - Do NOT put your private key in the repo. Set it in env or secrets at runtime. + */ + +const TronWeb = require('tronweb'); +require('dotenv').config(); + +const SERVER_PRIVATE_KEY = process.env.SERVER_PRIVATE_KEY; +const TRONGRID_URL = process.env.TRONGRID_URL || 'https://api.trongrid.io'; +const USDT_CONTRACT = process.env.USDT_CONTRACT || 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj'; + +if (!SERVER_PRIVATE_KEY) { + console.error('ERROR: SERVER_PRIVATE_KEY is not set in the environment. Aborting.'); + process.exit(1); +} + +const tronWeb = new TronWeb({ fullHost: TRONGRID_URL, privateKey: SERVER_PRIVATE_KEY }); + +async function main() { + try { + const argv = process.argv.slice(2); + let target = process.env.SWEEP_TARGET || null; + let dryRun = false; + + for (const a of argv) { + if (a === '--dry-run') dryRun = true; + else if (a === '--help' || a === '-h') { + console.log('Usage: node sweep.js [--dry-run]'); + process.exit(0); + } else if (!a.startsWith('--')) { + target = a; + } + } + + if (!target) { + console.error('ERROR: No target address provided. Provide as CLI arg or set SWEEP_TARGET env variable.'); + process.exit(1); + } + + // infer source (base58) from private key + let sourceAddr; + try { + sourceAddr = tronWeb.address.fromPrivateKey(SERVER_PRIVATE_KEY); + } catch (e) { + // fallback: try tronWeb.defaultAddress + sourceAddr = (tronWeb.defaultAddress && tronWeb.defaultAddress.base58) || null; + } + + if (!sourceAddr) { + console.error('ERROR: Unable to infer source address from private key.'); + process.exit(1); + } + + console.log(`Source address: ${sourceAddr}`); + console.log(`Target address: ${target}`); + console.log(`USDT contract: ${USDT_CONTRACT}`); + + // get USDT balance + const contract = await tronWeb.contract().at(USDT_CONTRACT); + // tronweb accepts base58 + const usdtSun = await contract.methods.balanceOf(sourceAddr).call(); + const usdtNum = Number(usdtSun) / 1e6; + + console.log(`USDT balance (smallest unit): ${usdtSun}`); + console.log(`USDT balance: ${usdtNum} USDT`); + + if (Number(usdtSun) === 0) { + console.log('No USDT balance to sweep. Exiting.'); + process.exit(0); + } + + if (dryRun) { + console.log('Dry run enabled. No transaction will be sent.'); + process.exit(0); + } + + console.log('Sending transaction... (this will sign with SERVER_PRIVATE_KEY)'); + + // call transfer + const tx = await contract.methods.transfer(target, String(usdtSun)).send(); + + console.log('Send result:', tx); + console.log('Done. Query transaction info with /api/tx/:txid endpoint or using TronGrid.'); + } catch (err) { + console.error('Sweep failed:', err && (err.message || err)); + process.exit(1); + } +} + +main();