Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cf87a764f | |||
| 4bb92209ca | |||
| 4393edf1d7 | |||
| 9a8fec2e9c | |||
| 101b31e073 | |||
| 11ad888ee7 | |||
| 53d8ded7f6 | |||
| e3e1d43d5d | |||
| 09b1944376 | |||
| e0912dd819 | |||
| f4c5f93370 | |||
| 6dcf582225 | |||
| 9ed7588ec0 | |||
| 87647cae34 | |||
| 620d8148f3 | |||
| 37258e1cb2 | |||
| 38be40a107 | |||
| 5b89de656e |
@@ -62,6 +62,14 @@ jobs:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
||||
# 境内镜像:GitHub release 资产在国内被 GFW 限速 → windows runner(LAN 内)下
|
||||
# sing-box.exe / wintun.zip 超时。改从 NAS Gitea generic 包镜像拉,
|
||||
# fetch-desktop-bin.sh 命中镜像后照样验 SHA256,失败则回退官方源。
|
||||
# ⚠️ 基址含 sing-box 版本目录(v1.13.12)——升级 app/kernel/VERSION 的
|
||||
# SINGBOX_VERSION 时,须把新版 zip 重新 PUT 到对应版本目录并同步改这里。
|
||||
DESKTOP_BIN_MIRROR: http://192.168.3.200:3000/api/packages/wangjia/generic/desktop-bin/v1.13.12
|
||||
# 包默认可匿名读,token 非必需;带上以防将来把包设为私有(未设/为空则匿名 GET)。
|
||||
DESKTOP_BIN_MIRROR_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
# 提取对应 arch 的 wintun.dll 到产物目录。
|
||||
#
|
||||
# 幂等: 产物已存在且 SHA256 校验通过则跳过下载(传 --force 强制重下)
|
||||
#
|
||||
# 境内镜像(可选,解决 CI 从 GitHub release 被 GFW 限速的问题):
|
||||
# DESKTOP_BIN_MIRROR 镜像基址;设了则 archive 与 wintun.zip 先试
|
||||
# ${DESKTOP_BIN_MIRROR}/<文件名>,命中即用,失败/未设
|
||||
# 则回退官方 GitHub / wintun.net。镜像下载的文件照样走
|
||||
# 下面的 SHA256 校验(防投毒/损坏)。
|
||||
# 例:http://192.168.3.200:3000/api/packages/wangjia/generic/desktop-bin/v1.13.12
|
||||
# DESKTOP_BIN_MIRROR_TOKEN 可选;镜像需鉴权时作 `Authorization: token <值>`。
|
||||
# 不设则匿名 GET(NAS Gitea generic 包默认可匿名读)。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -116,6 +125,36 @@ _sha256() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 下载助手:镜像优先,回退官方源 ─────────────────────────────────────────────
|
||||
# 用法: _download_with_mirror <文件名> <官方回退URL> <输出路径>
|
||||
# 若设了 DESKTOP_BIN_MIRROR:先试 ${DESKTOP_BIN_MIRROR}/<文件名>(带可选 token
|
||||
# header),命中即返回;未设 / 镜像失败则回退官方 <回退URL>。
|
||||
# SHA256 校验由调用方在下载后统一执行——镜像来的文件同样要过校验。
|
||||
_download_with_mirror() {
|
||||
local filename="$1" fallback_url="$2" out="$3"
|
||||
|
||||
if [[ -n "${DESKTOP_BIN_MIRROR:-}" ]]; then
|
||||
local mirror_url="${DESKTOP_BIN_MIRROR%/}/${filename}"
|
||||
local -a auth=()
|
||||
if [[ -n "${DESKTOP_BIN_MIRROR_TOKEN:-}" ]]; then
|
||||
auth=(-H "Authorization: token ${DESKTOP_BIN_MIRROR_TOKEN}")
|
||||
fi
|
||||
printf '==> [mirror] 尝试 %s…\n' "${mirror_url}"
|
||||
# ${auth[@]+...} 兜住空数组 + set -u:macOS bash 3.2 下 "${auth[@]}" 在数组
|
||||
# 为空时会报 "unbound variable",此写法数组空则整体展开为空。
|
||||
if curl -fSL --retry 5 --retry-delay 3 --retry-all-errors --connect-timeout 15 \
|
||||
${auth[@]+"${auth[@]}"} -o "${out}" "${mirror_url}"; then
|
||||
printf ' ✓ [mirror] 命中\n'
|
||||
return 0
|
||||
fi
|
||||
printf ' ! [mirror] 未命中,回退官方源\n' >&2
|
||||
fi
|
||||
|
||||
printf '==> 下载 %s…\n' "${fallback_url}"
|
||||
curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \
|
||||
-o "${out}" "${fallback_url}"
|
||||
}
|
||||
|
||||
# ── 幂等检查(已有产物则跳过;--force 强制重下)────────────────────────────────
|
||||
if [[ "${FORCE}" == false && -f "${OUT_BIN}" ]]; then
|
||||
printf '✓ %s 已存在,跳过下载\n' "${OUT_BIN}"
|
||||
@@ -138,11 +177,8 @@ if [[ -z "${EXPECTED_HASH}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── 下载二进制压缩包 ──────────────────────────────────────────────────────────
|
||||
printf '==> 下载 %s…\n' "${ARCHIVE_FILE}"
|
||||
curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \
|
||||
-o "${ARCHIVE_CACHE}" \
|
||||
"${ARCHIVE_URL}"
|
||||
# ── 下载二进制压缩包(镜像优先,回退 GitHub Release)──────────────────────────
|
||||
_download_with_mirror "${ARCHIVE_FILE}" "${ARCHIVE_URL}" "${ARCHIVE_CACHE}"
|
||||
|
||||
# ── SHA256 校验(对压缩包,比对内置 pin)──────────────────────────────────────
|
||||
printf '==> 校验 SHA256…\n'
|
||||
@@ -197,9 +233,8 @@ if [[ "${TARGET_OS}" == "windows" ]]; then
|
||||
if [[ "${FORCE}" == false && -f "${WINTUN_OUT}" ]]; then
|
||||
printf '✓ wintun.dll 已存在,跳过(传 --force 重新下载)\n'
|
||||
else
|
||||
curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \
|
||||
-o "${WINTUN_ZIP_CACHE}" \
|
||||
"${WINTUN_URL}"
|
||||
# 镜像优先,回退 wintun.net
|
||||
_download_with_mirror "${WINTUN_ZIP_NAME}" "${WINTUN_URL}" "${WINTUN_ZIP_CACHE}"
|
||||
|
||||
printf '==> 校验 wintun.zip SHA256…\n'
|
||||
WINTUN_ACTUAL="$(_sha256 "${WINTUN_ZIP_CACHE}")"
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
||||
import 'package:pangolin_vpn/pangolin_theme.dart';
|
||||
import 'package:pangolin_vpn/screens/stats_page.dart';
|
||||
import 'package:pangolin_vpn/services/api_client.dart';
|
||||
@@ -38,7 +37,6 @@ class _LoggedIn implements TokenStore {
|
||||
|
||||
void main() {
|
||||
setUpAll(disableGoogleFontsFetching);
|
||||
const t = StringsZh();
|
||||
|
||||
testWidgets('选设备 → /v1/usage 带 device=<uuid>', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(900, 1200));
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# pangolin-pay
|
||||
|
||||
自托管 USDT(TRC20) 收款服务。给每个订单派生一个**唯一收款地址**(watch-only,从账户
|
||||
xpub 派生,**不持私钥**),轮询 TronGrid 侦测到账、确认后标记订单已付。归集(把钱扫到冷
|
||||
钱包)是**独立的离线步骤**,本服务不碰私钥。
|
||||
|
||||
> 属 #34「独角数卡 + USDT 收款闭环」的加密货币交易引擎(计划见
|
||||
> `docs/superpowers/plans/2026-07-09-crypto-tx-engine.md`)。概念见 brain
|
||||
> `notes/dev/crypto-hd-wallet-basics.html`。
|
||||
|
||||
## 安全模型(必读)
|
||||
|
||||
- 本服务(热、联网)**只持 account xpub**——能派生收款地址、能查到账,**拿不到任何私钥**。被脱库也转不走钱。
|
||||
- 私钥/助记词**冷存**;要动钱(归集)时才在**离线端**用私钥签名(见 seed.go,OFFLINE ONLY)。
|
||||
- 密钥(xpub / TronGrid key)走 Bitwarden,不入 git、不写死。
|
||||
|
||||
## Phase A —— 离线准备钱包(你在断网机器上做)
|
||||
|
||||
1. 断网,用 Ian Coleman `bip39-standalone.html`(或 `bip_utils`)生成**两套** 24 词助记词:
|
||||
钱包 A(运营收款)、钱包 B(冷备金库)。Coin=TRX、English。
|
||||
2. 取**钱包 A 的 Account Extended Public Key**(`m/44'/195'/0'` 的 xpub)→ 就是本服务的 `PAY_ACCOUNT_XPUB`。
|
||||
3. 取**钱包 B 的地址0**(`T...`)→ 归集目标(Phase D 用)。
|
||||
4. **交叉核对(关键)**:本仓自带的金标准向量(测试助记词 `abandon…about`)必须与 Ian Coleman
|
||||
一致 —— 跑 `go test ./internal/wallet -run TestKnownVector -v`,再在 Ian Coleman 里用同一测试
|
||||
助记词、Coin=TRX 对照前 3 个地址。一致 = 派生实现可信;不一致 = 有 bug,别上线。
|
||||
5. 助记词 A/B 分开冷存 + Bitwarden。**只把 xpub_A 交给本服务。**
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
export PAY_ACCOUNT_XPUB="xpub..." # 钱包 A 的 account xpub(必填)
|
||||
export TRONGRID_API_KEY="..." # TronGrid key(建议)
|
||||
export PAY_DB="pay.db" # SQLite 路径(默认 pay.db)
|
||||
export PAY_ADDR=":8090" # 监听(默认 :8090)
|
||||
export PAY_POLL_SECONDS="20" # 轮询间隔秒(默认 20)
|
||||
# USDT_CONTRACT / TRONGRID_BASE 默认主网
|
||||
go run ./cmd/paywatch
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
POST /order {"sku":"pro-year","amount":5000000} # amount = micro-USDT(1e-6)
|
||||
→ 201 {"order_no","address","expect_amount","status":"pending","expires_at"}
|
||||
GET /order/{orderNo} → 200 {..., "status":"pending|paid|expired","tx_id"}
|
||||
GET /healthz → 200 ok
|
||||
```
|
||||
|
||||
门面(独角数卡)下单时调 `POST /order` 拿收款地址;支付页轮询 `GET /order/{id}` 直到 `paid`。
|
||||
|
||||
## Phase D —— 归集(气隙签名,`cmd/sweep`)
|
||||
|
||||
把散在各收款地址的 USDT 扫到冷钱包,**助记词只在离线机上出现**,联网机永远拿不到私钥。
|
||||
四段式,跨气隙用文件传递(unsigned.json / signed.json):
|
||||
|
||||
```bash
|
||||
# ① 联网:列出有余额的收款地址
|
||||
PAY_ACCOUNT_XPUB=xpub... TRONGRID_API_KEY=... go run ./cmd/sweep plan --max 50
|
||||
|
||||
# ② 联网:构造未签名转账(全额 → 冷钱包),不碰私钥
|
||||
PAY_ACCOUNT_XPUB=xpub... TRONGRID_API_KEY=... \
|
||||
go run ./cmd/sweep build --cold TColdAddr... --max 50 --fee-limit 30000000 > unsigned.json
|
||||
|
||||
# ③ 离线(断网机):从 Bitwarden 取助记词进环境,独立验签后签名
|
||||
PAY_SWEEP_MNEMONIC="word1 ... word24" \
|
||||
go run ./cmd/sweep sign --cold TColdAddr... < unsigned.json > signed.json
|
||||
|
||||
# ④ 联网:广播
|
||||
TRONGRID_API_KEY=... go run ./cmd/sweep broadcast < signed.json
|
||||
```
|
||||
|
||||
**`sign` 独立验签(气隙安全的关键)**——对每笔交易:① 重算 txid=sha256(raw_data) 必须等于声称值
|
||||
(防 raw_data 被篡改);② 收款人+金额的 ABI 参数必须内嵌在 raw_data(防换收款人/改额);③ USDT 合约
|
||||
必须内嵌(防换币);④ 由助记词派生的地址必须等于 owner(防错钥匙)。任一不符即拒签。
|
||||
|
||||
**gas**:TRC20 转账要 energy,收款地址身上没 TRX——归集前先给这些地址垫少量 TRX(gas 钱包),
|
||||
或用能量租赁。(垫 gas 的辅助后续加;当前 `build` 已设 `--fee-limit`。)
|
||||
|
||||
> ⚠️ 联网上链部分(`build`/余额/`broadcast`)只在真链(Phase E)验证;纯 crypto
|
||||
> (地址解码/ABI/txid/签名可恢复)已单测。首次务必**小额**实跑一遍再放量。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
- `wallet`:派生一致性(xpub 路径 == 私钥路径)+ 金标准向量(需 A.4 核对)。
|
||||
- `store`/`pay`/`watcher`/`httpapi`:建单/派生地址/侦测到账/幂等/超时/HTTP。
|
||||
@@ -0,0 +1,85 @@
|
||||
// Command paywatch is the pangolin-pay service: it hands out per-order TRON
|
||||
// receiving addresses (watch-only, derived from an account xpub), watches
|
||||
// TronGrid for confirmed USDT payments, and marks orders paid. It holds NO
|
||||
// private keys — sweeping funds to cold storage is a separate offline step.
|
||||
//
|
||||
// Env:
|
||||
//
|
||||
// PAY_ACCOUNT_XPUB (required) watch-only account xpub, m/44'/195'/0'
|
||||
// PAY_DB SQLite path (default pay.db)
|
||||
// PAY_ADDR HTTP listen addr (default :8090)
|
||||
// PAY_POLL_SECONDS watcher poll interval (default 20)
|
||||
// TRONGRID_BASE TronGrid base URL (default https://api.trongrid.io)
|
||||
// TRONGRID_API_KEY TronGrid API key (recommended)
|
||||
// USDT_CONTRACT TRC20 USDT contract (default mainnet)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/httpapi"
|
||||
"github.com/wangjia/pangolin/pay/internal/pay"
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
"github.com/wangjia/pangolin/pay/internal/tron"
|
||||
"github.com/wangjia/pangolin/pay/internal/watcher"
|
||||
)
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
xpub := os.Getenv("PAY_ACCOUNT_XPUB")
|
||||
if xpub == "" {
|
||||
log.Error("PAY_ACCOUNT_XPUB is required (watch-only account xpub, m/44'/195'/0')")
|
||||
os.Exit(1)
|
||||
}
|
||||
dbPath := env("PAY_DB", "pay.db")
|
||||
addr := env("PAY_ADDR", ":8090")
|
||||
pollSec, _ := strconv.Atoi(env("PAY_POLL_SECONDS", "20"))
|
||||
if pollSec <= 0 {
|
||||
pollSec = 20
|
||||
}
|
||||
|
||||
st, err := store.Open("file:" + dbPath + "?_txlock=immediate")
|
||||
if err != nil {
|
||||
log.Error("open store", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = st.Close() }()
|
||||
|
||||
svc := pay.New(st, pay.Config{AccountXpub: xpub, OrderTTL: 15 * time.Minute})
|
||||
fetcher := tron.NewClient(env("TRONGRID_BASE", ""), env("USDT_CONTRACT", ""), os.Getenv("TRONGRID_API_KEY"))
|
||||
w := watcher.New(st, fetcher, log)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go w.Loop(ctx, time.Duration(pollSec)*time.Second)
|
||||
|
||||
srv := &http.Server{Addr: addr, Handler: httpapi.New(svc), ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() {
|
||||
log.Info("pangolin-pay listening", "addr", addr, "poll_seconds", pollSec)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Error("http server", "err", err)
|
||||
stop()
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
sc, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(sc)
|
||||
log.Info("pangolin-pay stopped")
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// Command sweep is the offline-signed USDT 归集 tool (Phase D). It splits the
|
||||
// work across the air gap so the mnemonic never touches an online machine:
|
||||
//
|
||||
// sweep plan (online) list derived receiving addresses that hold USDT
|
||||
// sweep build (online) build unsigned transfers -> unsigned.json (no key)
|
||||
// sweep sign (OFFLINE) verify + sign with the mnemonic -> signed.json
|
||||
// sweep broadcast (online) submit signed.json to the chain
|
||||
//
|
||||
// The mnemonic is read from env PAY_SWEEP_MNEMONIC (set on the air-gapped box
|
||||
// from Bitwarden), never a CLI arg and never on the hot service. `sign`
|
||||
// independently re-verifies every transaction (recomputes the txid from
|
||||
// raw_data, checks the recipient+amount+contract are embedded, and checks the
|
||||
// derived key matches the owner) so a compromised online builder cannot trick it
|
||||
// into signing a payment to someone else.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/tron"
|
||||
"github.com/wangjia/pangolin/pay/internal/wallet"
|
||||
)
|
||||
|
||||
type item struct {
|
||||
Index uint32 `json:"index"`
|
||||
Owner string `json:"owner"`
|
||||
Cold string `json:"cold"`
|
||||
Amount int64 `json:"amount"` // micro-USDT
|
||||
Unsigned tron.UnsignedTx `json:"unsigned"`
|
||||
Signed *tron.SignedTx `json:"signed,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fail("usage: sweep <plan|build|sign|broadcast> [flags]")
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "plan":
|
||||
cmdPlan(os.Args[2:])
|
||||
case "build":
|
||||
cmdBuild(os.Args[2:])
|
||||
case "sign":
|
||||
cmdSign(os.Args[2:])
|
||||
case "broadcast":
|
||||
cmdBroadcast(os.Args[2:])
|
||||
default:
|
||||
fail("unknown subcommand %q (plan|build|sign|broadcast)", os.Args[1])
|
||||
}
|
||||
}
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func usdtContract() string { return env("USDT_CONTRACT", tron.USDTContractMainnet) }
|
||||
|
||||
func newClient() *tron.Client {
|
||||
return tron.NewClient(env("TRONGRID_BASE", ""), usdtContract(), os.Getenv("TRONGRID_API_KEY"))
|
||||
}
|
||||
|
||||
// cmdPlan (online): scan indices 0..max, print those with a USDT balance.
|
||||
func cmdPlan(args []string) {
|
||||
fs := flag.NewFlagSet("plan", flag.ExitOnError)
|
||||
max := fs.Uint("max", 50, "highest address index to scan")
|
||||
_ = fs.Parse(args)
|
||||
xpub := mustEnv("PAY_ACCOUNT_XPUB")
|
||||
c := newClient()
|
||||
ctx := context.Background()
|
||||
var total int64
|
||||
for i := uint32(0); i <= uint32(*max); i++ {
|
||||
addr, err := wallet.AddressFromAccountXpub(xpub, 0, i)
|
||||
if err != nil {
|
||||
fail("derive %d: %v", i, err)
|
||||
}
|
||||
bal, err := c.TRC20Balance(ctx, addr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warn: balance %d %s: %v\n", i, addr, err)
|
||||
continue
|
||||
}
|
||||
if bal > 0 {
|
||||
total += bal
|
||||
fmt.Printf("index=%d\taddr=%s\tusdt=%s\n", i, addr, usdt(bal))
|
||||
}
|
||||
}
|
||||
fmt.Printf("TOTAL: %s USDT\n", usdt(total))
|
||||
}
|
||||
|
||||
// cmdBuild (online): build unsigned transfers of each address's full balance to
|
||||
// the cold wallet. No private key used.
|
||||
func cmdBuild(args []string) {
|
||||
fs := flag.NewFlagSet("build", flag.ExitOnError)
|
||||
cold := fs.String("cold", "", "cold wallet destination address (T...)")
|
||||
max := fs.Uint("max", 50, "highest address index to scan")
|
||||
feeLimit := fs.Int64("fee-limit", 30_000_000, "fee limit in sun (1e-6 TRX)")
|
||||
_ = fs.Parse(args)
|
||||
if *cold == "" {
|
||||
fail("--cold is required")
|
||||
}
|
||||
if _, err := wallet.DecodeTronAddress(*cold); err != nil {
|
||||
fail("bad --cold: %v", err)
|
||||
}
|
||||
xpub := mustEnv("PAY_ACCOUNT_XPUB")
|
||||
c := newClient()
|
||||
ctx := context.Background()
|
||||
var out []item
|
||||
for i := uint32(0); i <= uint32(*max); i++ {
|
||||
owner, err := wallet.AddressFromAccountXpub(xpub, 0, i)
|
||||
if err != nil {
|
||||
fail("derive %d: %v", i, err)
|
||||
}
|
||||
bal, err := c.TRC20Balance(ctx, owner)
|
||||
if err != nil || bal <= 0 {
|
||||
continue
|
||||
}
|
||||
ut, err := c.BuildTransfer(ctx, owner, *cold, bal, *feeLimit)
|
||||
if err != nil {
|
||||
fail("build %d %s: %v", i, owner, err)
|
||||
}
|
||||
out = append(out, item{Index: i, Owner: owner, Cold: *cold, Amount: bal, Unsigned: *ut})
|
||||
fmt.Fprintf(os.Stderr, "built index=%d %s -> %s %s USDT\n", i, owner, *cold, usdt(bal))
|
||||
}
|
||||
emit(out)
|
||||
}
|
||||
|
||||
// cmdSign (OFFLINE): verify each tx independently, then sign with the mnemonic.
|
||||
func cmdSign(args []string) {
|
||||
fs := flag.NewFlagSet("sign", flag.ExitOnError)
|
||||
cold := fs.String("cold", "", "expected cold destination (guards against tampering)")
|
||||
_ = fs.Parse(args)
|
||||
mnemonic := mustEnv("PAY_SWEEP_MNEMONIC") // set on the air-gapped box from Bitwarden
|
||||
items := read()
|
||||
contractBodyHex, err := wallet.TronAddressBodyHex(usdtContract())
|
||||
if err != nil {
|
||||
fail("usdt contract: %v", err)
|
||||
}
|
||||
for i := range items {
|
||||
it := &items[i]
|
||||
|
||||
if *cold != "" && it.Cold != *cold {
|
||||
fail("index %d: cold %s != expected %s", it.Index, it.Cold, *cold)
|
||||
}
|
||||
// 1) raw_data integrity: recompute txid, must equal the claimed one.
|
||||
txid, err := tron.TxID(it.Unsigned.RawDataHex)
|
||||
if err != nil {
|
||||
fail("index %d: txid: %v", it.Index, err)
|
||||
}
|
||||
if !strings.EqualFold(hex.EncodeToString(txid), it.Unsigned.TxID) {
|
||||
fail("index %d: txid mismatch — raw_data tampered", it.Index)
|
||||
}
|
||||
// 2) recipient + amount: the exact ABI param must be embedded in raw_data.
|
||||
wantParam, err := tron.ABIEncodeTransferParams(it.Cold, it.Amount)
|
||||
if err != nil {
|
||||
fail("index %d: abi: %v", it.Index, err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(it.Unsigned.RawDataHex), strings.ToLower(wantParam)) {
|
||||
fail("index %d: recipient/amount not found in raw_data — refusing to sign", it.Index)
|
||||
}
|
||||
// 3) contract: the USDT contract body must be in raw_data (right token).
|
||||
if !strings.Contains(strings.ToLower(it.Unsigned.RawDataHex), strings.ToLower(contractBodyHex)) {
|
||||
fail("index %d: USDT contract not found in raw_data — refusing to sign", it.Index)
|
||||
}
|
||||
// 4) key: the derived address for this index must equal the owner.
|
||||
addr, err := wallet.AddressFromMnemonic(mnemonic, "", 0, 0, it.Index)
|
||||
if err != nil {
|
||||
fail("index %d: derive addr: %v", it.Index, err)
|
||||
}
|
||||
if addr != it.Owner {
|
||||
fail("index %d: derived %s != owner %s — wrong mnemonic/index", it.Index, addr, it.Owner)
|
||||
}
|
||||
|
||||
priv, err := wallet.PrivKeyHexFromMnemonic(mnemonic, "", 0, 0, it.Index)
|
||||
if err != nil {
|
||||
fail("index %d: privkey: %v", it.Index, err)
|
||||
}
|
||||
sig, err := tron.SignRawData(it.Unsigned.RawDataHex, priv)
|
||||
if err != nil {
|
||||
fail("index %d: sign: %v", it.Index, err)
|
||||
}
|
||||
it.Signed = &tron.SignedTx{
|
||||
TxID: it.Unsigned.TxID,
|
||||
RawData: it.Unsigned.RawData,
|
||||
RawDataHex: it.Unsigned.RawDataHex,
|
||||
Visible: it.Unsigned.Visible,
|
||||
Signature: []string{sig},
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "signed index=%d %s -> %s %s USDT\n", it.Index, it.Owner, it.Cold, usdt(it.Amount))
|
||||
}
|
||||
emit(items)
|
||||
}
|
||||
|
||||
// cmdBroadcast (online): submit each signed tx.
|
||||
func cmdBroadcast(args []string) {
|
||||
_ = flag.NewFlagSet("broadcast", flag.ExitOnError).Parse(args)
|
||||
items := read()
|
||||
c := newClient()
|
||||
ctx := context.Background()
|
||||
for i := range items {
|
||||
it := &items[i]
|
||||
if it.Signed == nil {
|
||||
fail("index %d: not signed", it.Index)
|
||||
}
|
||||
txid, err := c.Broadcast(ctx, it.Signed)
|
||||
if err != nil {
|
||||
fail("index %d: broadcast: %v", it.Index, err)
|
||||
}
|
||||
fmt.Printf("broadcast index=%d %s USDT -> tx %s\n", it.Index, usdt(it.Amount), txid)
|
||||
time.Sleep(200 * time.Millisecond) // be gentle with the endpoint
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func usdt(micro int64) string {
|
||||
return strconv.FormatFloat(float64(micro)/1e6, 'f', 6, 64)
|
||||
}
|
||||
|
||||
func mustEnv(k string) string {
|
||||
v := os.Getenv(k)
|
||||
if v == "" {
|
||||
fail("env %s is required", k)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func read() []item {
|
||||
var items []item
|
||||
if err := json.NewDecoder(os.Stdin).Decode(&items); err != nil {
|
||||
fail("read json from stdin: %v", err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func emit(items []item) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(items); err != nil {
|
||||
fail("write json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fail(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, "sweep: "+format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
module github.com/wangjia/pangolin/pay
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/btcsuite/btcd v0.24.2
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.5
|
||||
github.com/btcsuite/btcd/btcutil v1.2.0
|
||||
github.com/tyler-smith/go-bip39 v1.1.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
|
||||
github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.5 h1:dpAlnAwmT1yIBm3exhT1/8iUSD98RDJM5vqJVQDQLiU=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.5/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ=
|
||||
github.com/btcsuite/btcd/btcutil v1.2.0 h1:p3+S2g3Q+7G5NOh4Ji+2UrBOrg5Z0Q4ykzShWG1Dhgs=
|
||||
github.com/btcsuite/btcd/btcutil v1.2.0/go.mod h1:/Taflm113pYjUpbWKKQEfa6XOtI/+WS8awxeMZpY75k=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee h1:FPP9HDkBbPyniu+u7FHZg+kKFX1WW0gxOGteJ0h3AJk=
|
||||
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee/go.mod h1:N6sz6HwJAenJ6d+/xmSl0ikfV05ZrVGmjt1ryy/WOtE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
|
||||
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package httpapi exposes the order endpoints a storefront (e.g. 独角数卡) calls:
|
||||
// create a payment (get a receiving address) and poll its status.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/pay"
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
)
|
||||
|
||||
type Handler struct{ svc *pay.Service }
|
||||
|
||||
// New wires the routes (Go 1.22 method+wildcard patterns).
|
||||
func New(svc *pay.Service) http.Handler {
|
||||
h := &Handler{svc: svc}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /order", h.createOrder)
|
||||
mux.HandleFunc("GET /order/{orderNo}", h.getOrder)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
type createReq struct {
|
||||
SKU string `json:"sku"`
|
||||
Amount int64 `json:"amount"` // micro-USDT (1e-6)
|
||||
}
|
||||
|
||||
type orderResp struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
Address string `json:"address"`
|
||||
ExpectAmount int64 `json:"expect_amount"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
TxID string `json:"tx_id,omitempty"`
|
||||
}
|
||||
|
||||
func toResp(o *store.Order) orderResp {
|
||||
return orderResp{
|
||||
OrderNo: o.OrderNo,
|
||||
Address: o.Address,
|
||||
ExpectAmount: o.ExpectAmount,
|
||||
Status: string(o.Status),
|
||||
ExpiresAt: o.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
TxID: o.TxID,
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func (h *Handler) createOrder(w http.ResponseWriter, r *http.Request) {
|
||||
var req createReq
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
o, err := h.svc.CreateOrder(r.Context(), req.SKU, req.Amount)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toResp(o))
|
||||
}
|
||||
|
||||
func (h *Handler) getOrder(w http.ResponseWriter, r *http.Request) {
|
||||
o, err := h.svc.GetOrder(r.Context(), r.PathValue("orderNo"))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toResp(o))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/pay"
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
)
|
||||
|
||||
const testXpub = "xpub6D1AabNHCupeiLM65ZR9UStMhJ1vCpyV4XbZdyhMZBiJXALQtmn9p42VTQckoHVn8WNqS7dqnJokZHAHcHGoaQgmv8D45oNUKx6DZMNZBCd"
|
||||
|
||||
func TestCreateAndGetOrder(t *testing.T) {
|
||||
st, _ := store.Open(":memory:")
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
srv := httptest.NewServer(New(pay.New(st, pay.Config{AccountXpub: testXpub})))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"sku": "pro-year", "amount": 5_000000})
|
||||
resp, err := http.Post(srv.URL+"/order", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create status %d", resp.StatusCode)
|
||||
}
|
||||
var created orderResp
|
||||
_ = json.NewDecoder(resp.Body).Decode(&created)
|
||||
_ = resp.Body.Close()
|
||||
if created.Address != "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH" || created.Status != "pending" {
|
||||
t.Fatalf("create resp: %+v", created)
|
||||
}
|
||||
|
||||
r2, _ := http.Get(srv.URL + "/order/" + created.OrderNo)
|
||||
if r2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get status %d", r2.StatusCode)
|
||||
}
|
||||
var got orderResp
|
||||
_ = json.NewDecoder(r2.Body).Decode(&got)
|
||||
_ = r2.Body.Close()
|
||||
if got.OrderNo != created.OrderNo || got.Address != created.Address {
|
||||
t.Fatalf("get mismatch: %+v vs %+v", got, created)
|
||||
}
|
||||
|
||||
r3, _ := http.Get(srv.URL + "/order/NOPE")
|
||||
if r3.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", r3.StatusCode)
|
||||
}
|
||||
_ = r3.Body.Close()
|
||||
|
||||
r4, _ := http.Post(srv.URL+"/order", "application/json", bytes.NewReader([]byte(`{"sku":"x","amount":0}`)))
|
||||
if r4.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 for bad amount, got %d", r4.StatusCode)
|
||||
}
|
||||
_ = r4.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package pay is the order service: create a payment (derive a fresh receiving
|
||||
// address, record a pending order) and look one up.
|
||||
package pay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
"github.com/wangjia/pangolin/pay/internal/wallet"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AccountXpub string // watch-only account xpub (m/44'/195'/0')
|
||||
OrderTTL time.Duration // how long a pending order stays payable
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
cfg Config
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(st *store.Store, cfg Config) *Service {
|
||||
if cfg.OrderTTL <= 0 {
|
||||
cfg.OrderTTL = 15 * time.Minute
|
||||
}
|
||||
return &Service{st: st, cfg: cfg, now: time.Now}
|
||||
}
|
||||
|
||||
// CreateOrder assigns a fresh HD receiving address and records a pending order.
|
||||
// amount is in micro-USDT (1e-6).
|
||||
func (s *Service) CreateOrder(ctx context.Context, sku string, amount int64) (*store.Order, error) {
|
||||
if amount <= 0 {
|
||||
return nil, fmt.Errorf("pay: amount must be positive")
|
||||
}
|
||||
if sku == "" {
|
||||
return nil, fmt.Errorf("pay: sku required")
|
||||
}
|
||||
idx, err := s.st.NextAddrIndex(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pay: next addr index: %w", err)
|
||||
}
|
||||
addr, err := wallet.AddressFromAccountXpub(s.cfg.AccountXpub, 0, idx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pay: derive address: %w", err)
|
||||
}
|
||||
now := s.now()
|
||||
o := &store.Order{
|
||||
OrderNo: newOrderNo(now),
|
||||
SKU: sku,
|
||||
ExpectAmount: amount,
|
||||
AddrIndex: idx,
|
||||
Address: addr,
|
||||
Status: store.StatusPending,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(s.cfg.OrderTTL),
|
||||
}
|
||||
if err := s.st.CreateOrder(ctx, o); err != nil {
|
||||
return nil, fmt.Errorf("pay: create order: %w", err)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetOrder(ctx context.Context, orderNo string) (*store.Order, error) {
|
||||
return s.st.GetOrder(ctx, orderNo)
|
||||
}
|
||||
|
||||
func newOrderNo(t time.Time) string {
|
||||
var b [6]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return fmt.Sprintf("PAY%s%x", t.UTC().Format("20060102150405"), b)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package pay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
)
|
||||
|
||||
// Same golden test-mnemonic account xpub as the wallet package. First two
|
||||
// receiving addresses (index 0,1) are locked so we prove CreateOrder assigns the
|
||||
// right HD address and advances the cursor.
|
||||
const testXpub = "xpub6D1AabNHCupeiLM65ZR9UStMhJ1vCpyV4XbZdyhMZBiJXALQtmn9p42VTQckoHVn8WNqS7dqnJokZHAHcHGoaQgmv8D45oNUKx6DZMNZBCd"
|
||||
|
||||
func TestCreateOrderDerivesSequentialAddresses(t *testing.T) {
|
||||
st, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
svc := New(st, Config{AccountXpub: testXpub})
|
||||
ctx := context.Background()
|
||||
|
||||
o0, err := svc.CreateOrder(ctx, "pro-year", 5_000000)
|
||||
if err != nil {
|
||||
t.Fatalf("order0: %v", err)
|
||||
}
|
||||
if o0.AddrIndex != 0 || o0.Address != "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH" {
|
||||
t.Fatalf("order0 addr: idx=%d addr=%s", o0.AddrIndex, o0.Address)
|
||||
}
|
||||
if o0.Status != store.StatusPending || o0.ExpiresAt.Before(o0.CreatedAt) {
|
||||
t.Fatalf("order0 state: %+v", o0)
|
||||
}
|
||||
|
||||
o1, err := svc.CreateOrder(ctx, "pro-month", 500000)
|
||||
if err != nil {
|
||||
t.Fatalf("order1: %v", err)
|
||||
}
|
||||
if o1.AddrIndex != 1 || o1.Address != "TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK" {
|
||||
t.Fatalf("order1 addr: idx=%d addr=%s", o1.AddrIndex, o1.Address)
|
||||
}
|
||||
if o1.Address == o0.Address {
|
||||
t.Fatal("addresses must not repeat across orders")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrderRejectsBadInput(t *testing.T) {
|
||||
st, _ := store.Open(":memory:")
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
svc := New(st, Config{AccountXpub: testXpub})
|
||||
ctx := context.Background()
|
||||
if _, err := svc.CreateOrder(ctx, "x", 0); err == nil {
|
||||
t.Fatal("expected error for non-positive amount")
|
||||
}
|
||||
if _, err := svc.CreateOrder(ctx, "", 100); err == nil {
|
||||
t.Fatal("expected error for empty sku")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package store persists pay orders + the HD address-derivation cursor in
|
||||
// SQLite (pure-Go modernc driver, no CGO — same choice as the control plane).
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusPaid Status = "paid"
|
||||
StatusExpired Status = "expired"
|
||||
)
|
||||
|
||||
// Order is one payment request. Amounts are in micro-USDT (1e-6), matching the
|
||||
// raw integer value of a TRC20 USDT transfer (USDT has 6 decimals).
|
||||
type Order struct {
|
||||
OrderNo string
|
||||
SKU string
|
||||
ExpectAmount int64
|
||||
AddrIndex uint32
|
||||
Address string
|
||||
Status Status
|
||||
TxID string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var ErrNotFound = errors.New("store: order not found")
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
func Open(dsn string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1) // SQLite: serialize writers, avoid "database is locked"
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS pay_orders(
|
||||
order_no TEXT PRIMARY KEY,
|
||||
sku TEXT NOT NULL,
|
||||
expect_amount INTEGER NOT NULL,
|
||||
addr_index INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
tx_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_orders_status ON pay_orders(status)`,
|
||||
`CREATE TABLE IF NOT EXISTS addr_cursor(id INTEGER PRIMARY KEY CHECK(id=1), next_index INTEGER NOT NULL)`,
|
||||
`INSERT OR IGNORE INTO addr_cursor(id, next_index) VALUES(1, 0)`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NextAddrIndex atomically returns the current HD index and advances the cursor.
|
||||
// Addresses are never reused (avoids an old payment landing on a recycled slot).
|
||||
func (s *Store) NextAddrIndex(ctx context.Context) (uint32, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var idx uint32
|
||||
if err := tx.QueryRowContext(ctx, `SELECT next_index FROM addr_cursor WHERE id=1`).Scan(&idx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE addr_cursor SET next_index=? WHERE id=1`, idx+1); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateOrder(ctx context.Context, o *Order) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO pay_orders(order_no,sku,expect_amount,addr_index,address,status,created_at,expires_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`,
|
||||
o.OrderNo, o.SKU, o.ExpectAmount, o.AddrIndex, o.Address, o.Status, o.CreatedAt.Unix(), o.ExpiresAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
const cols = `order_no,sku,expect_amount,addr_index,address,status,tx_id,created_at,expires_at`
|
||||
|
||||
func scanOrder(sc interface{ Scan(...any) error }) (*Order, error) {
|
||||
o := &Order{}
|
||||
var created, expires int64
|
||||
if err := sc.Scan(&o.OrderNo, &o.SKU, &o.ExpectAmount, &o.AddrIndex, &o.Address, &o.Status, &o.TxID, &created, &expires); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.CreatedAt = time.Unix(created, 0)
|
||||
o.ExpiresAt = time.Unix(expires, 0)
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOrder(ctx context.Context, orderNo string) (*Order, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+cols+` FROM pay_orders WHERE order_no=?`, orderNo)
|
||||
o, err := scanOrder(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return o, err
|
||||
}
|
||||
|
||||
func (s *Store) ListPending(ctx context.Context) ([]*Order, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+cols+` FROM pay_orders WHERE status=?`, StatusPending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []*Order
|
||||
for rows.Next() {
|
||||
o, err := scanOrder(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkPaid transitions pending->paid, idempotently (only affects a still-pending
|
||||
// row). Returns true if this call was the one that flipped it.
|
||||
func (s *Store) MarkPaid(ctx context.Context, orderNo, txID string) (bool, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE pay_orders SET status=?, tx_id=? WHERE order_no=? AND status=?`,
|
||||
StatusPaid, txID, orderNo, StatusPending)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// MarkExpired flips pending->expired for orders past their deadline.
|
||||
func (s *Store) MarkExpired(ctx context.Context, now time.Time) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE pay_orders SET status=? WHERE status=? AND expires_at < ?`,
|
||||
StatusExpired, StatusPending, now.Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func openMem(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestNextAddrIndexMonotonic(t *testing.T) {
|
||||
s := openMem(t)
|
||||
ctx := context.Background()
|
||||
for want := uint32(0); want < 5; want++ {
|
||||
got, err := s.NextAddrIndex(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("next: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("index got %d want %d", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderRoundtripAndMarkPaidIdempotent(t *testing.T) {
|
||||
s := openMem(t)
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
o := &Order{
|
||||
OrderNo: "PAY1", SKU: "pro-year", ExpectAmount: 5_000000, AddrIndex: 0,
|
||||
Address: "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", Status: StatusPending,
|
||||
CreatedAt: now, ExpiresAt: now.Add(15 * time.Minute),
|
||||
}
|
||||
if err := s.CreateOrder(ctx, o); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
got, err := s.GetOrder(ctx, "PAY1")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.SKU != "pro-year" || got.ExpectAmount != 5_000000 || got.Status != StatusPending {
|
||||
t.Fatalf("roundtrip mismatch: %+v", got)
|
||||
}
|
||||
|
||||
ok, err := s.MarkPaid(ctx, "PAY1", "tx-abc")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("first MarkPaid ok=%v err=%v (want true,nil)", ok, err)
|
||||
}
|
||||
ok2, err := s.MarkPaid(ctx, "PAY1", "tx-dup")
|
||||
if err != nil || ok2 {
|
||||
t.Fatalf("second MarkPaid ok=%v err=%v (want false,nil — idempotent)", ok2, err)
|
||||
}
|
||||
got, _ = s.GetOrder(ctx, "PAY1")
|
||||
if got.Status != StatusPaid || got.TxID != "tx-abc" {
|
||||
t.Fatalf("after paid: status=%s tx=%s (want paid,tx-abc)", got.Status, got.TxID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkExpired(t *testing.T) {
|
||||
s := openMem(t)
|
||||
ctx := context.Background()
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
past := &Order{OrderNo: "old", SKU: "x", ExpectAmount: 1, Address: "T1", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(1 * time.Minute)}
|
||||
future := &Order{OrderNo: "new", SKU: "x", ExpectAmount: 1, Address: "T2", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(1 * time.Hour)}
|
||||
_ = s.CreateOrder(ctx, past)
|
||||
_ = s.CreateOrder(ctx, future)
|
||||
|
||||
n, err := s.MarkExpired(ctx, base.Add(10*time.Minute))
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("MarkExpired n=%d err=%v (want 1)", n, err)
|
||||
}
|
||||
oldO, _ := s.GetOrder(ctx, "old")
|
||||
newO, _ := s.GetOrder(ctx, "new")
|
||||
if oldO.Status != StatusExpired {
|
||||
t.Fatalf("old should be expired, got %s", oldO.Status)
|
||||
}
|
||||
if newO.Status != StatusPending {
|
||||
t.Fatalf("new should still be pending, got %s", newO.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/wallet"
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// TransferSelector is the TRC20 transfer(address,uint256) function selector
|
||||
// string that TronGrid's triggersmartcontract expects.
|
||||
const TransferSelector = "transfer(address,uint256)"
|
||||
|
||||
// ABIEncodeTransferParams builds the 64-byte ABI parameter for
|
||||
// transfer(address,uint256): the recipient (20-byte body, left-padded to 32) and
|
||||
// the amount (uint256, left-padded to 32). Returns hex (no 0x, no 4-byte
|
||||
// selector — TronGrid derives the selector from TransferSelector).
|
||||
func ABIEncodeTransferParams(toAddr string, amount int64) (string, error) {
|
||||
if amount <= 0 {
|
||||
return "", fmt.Errorf("tron: transfer amount must be positive")
|
||||
}
|
||||
payload, err := wallet.DecodeTronAddress(toAddr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := make([]byte, 64)
|
||||
copy(out[12:32], payload[1:]) // 20-byte body, right-aligned in first word
|
||||
new(big.Int).SetInt64(amount).FillBytes(out[32:64])
|
||||
return hex.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
// keccakAddressBody derives the 20-byte address body from a 65-byte uncompressed
|
||||
// secp256k1 public key: keccak256(X||Y)[12:].
|
||||
func keccakAddressBody(uncompressed []byte) []byte {
|
||||
h := sha3.NewLegacyKeccak256()
|
||||
h.Write(uncompressed[1:])
|
||||
sum := h.Sum(nil)
|
||||
return sum[12:]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Package tron reads confirmed incoming TRC20 (USDT) transfers from TronGrid.
|
||||
// Only reads — the watcher never signs or moves funds (that's offline sweeping).
|
||||
package tron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// USDTContractMainnet is the TRON mainnet USDT (TRC20) contract. 6 decimals.
|
||||
// ⚠️ Verify before relying on it in production (Phase-level constant check).
|
||||
const USDTContractMainnet = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||
|
||||
// Transfer is one confirmed incoming TRC20 transfer to a watched address.
|
||||
// Value is the raw integer amount (micro-USDT, since USDT has 6 decimals).
|
||||
type Transfer struct {
|
||||
TxID string
|
||||
To string
|
||||
Value int64
|
||||
}
|
||||
|
||||
// Fetcher returns confirmed incoming USDT transfers to a given address.
|
||||
type Fetcher interface {
|
||||
IncomingTransfers(ctx context.Context, address string) ([]Transfer, error)
|
||||
}
|
||||
|
||||
// Client talks to the TronGrid HTTP API.
|
||||
type Client struct {
|
||||
base string
|
||||
usdtContract string
|
||||
apiKey string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func NewClient(base, usdtContract, apiKey string) *Client {
|
||||
if base == "" {
|
||||
base = "https://api.trongrid.io"
|
||||
}
|
||||
if usdtContract == "" {
|
||||
usdtContract = USDTContractMainnet
|
||||
}
|
||||
return &Client{base: base, usdtContract: usdtContract, apiKey: apiKey, hc: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
func (c *Client) IncomingTransfers(ctx context.Context, address string) ([]Transfer, error) {
|
||||
u := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20?only_confirmed=true&contract_address=%s&limit=50",
|
||||
c.base, url.PathEscape(address), url.QueryEscape(c.usdtContract))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("tron: trongrid status %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data []struct {
|
||||
TransactionID string `json:"transaction_id"`
|
||||
To string `json:"to"`
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("tron: decode: %w", err)
|
||||
}
|
||||
out := make([]Transfer, 0, len(body.Data))
|
||||
for _, d := range body.Data {
|
||||
if d.To != address || d.Type != "Transfer" {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseInt(d.Value, 10, 64)
|
||||
if err != nil {
|
||||
continue // skip malformed value rather than fail the whole batch
|
||||
}
|
||||
out = append(out, Transfer{TxID: d.TransactionID, To: d.To, Value: v})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcec/v2/ecdsa"
|
||||
)
|
||||
|
||||
// TxID computes the TRON transaction id: sha256 of the raw_data bytes. The
|
||||
// signature is made over this hash. Recomputing it offline from raw_data_hex
|
||||
// (rather than trusting a txID handed over by the online builder) is what makes
|
||||
// air-gapped signing safe — a tampered raw_data yields a different id.
|
||||
func TxID(rawDataHex string) ([]byte, error) {
|
||||
raw, err := hex.DecodeString(rawDataHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tron: raw_data hex: %w", err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("tron: empty raw_data")
|
||||
}
|
||||
h := sha256.Sum256(raw)
|
||||
return h[:], nil
|
||||
}
|
||||
|
||||
// SignRawData signs raw_data with a hex private key and returns the 65-byte TRON
|
||||
// signature hex: R(32) || S(32) || recid(1, value 0/1). OFFLINE ONLY.
|
||||
func SignRawData(rawDataHex, privHex string) (string, error) {
|
||||
txid, err := TxID(rawDataHex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pb, err := hex.DecodeString(privHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tron: privkey hex: %w", err)
|
||||
}
|
||||
priv, _ := btcec.PrivKeyFromBytes(pb)
|
||||
|
||||
// SignCompact returns 65 bytes: [header || R || S], header = 27+recid for an
|
||||
// uncompressed key. TRON wants R || S || recid, so rearrange.
|
||||
compact := ecdsa.SignCompact(priv, txid, false)
|
||||
if len(compact) != 65 {
|
||||
return "", fmt.Errorf("tron: unexpected compact signature length %d", len(compact))
|
||||
}
|
||||
recid := compact[0] - 27
|
||||
sig := make([]byte, 0, 65)
|
||||
sig = append(sig, compact[1:65]...) // R || S
|
||||
sig = append(sig, recid) // recovery id 0/1
|
||||
return hex.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// RecoverAddressBody recovers the signer's 20-byte address body from a raw_data
|
||||
// hex + TRON signature hex — used by tests (and could verify a signature).
|
||||
func RecoverAddressBody(rawDataHex, sigHex string) ([]byte, error) {
|
||||
txid, err := TxID(rawDataHex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sig, err := hex.DecodeString(sigHex)
|
||||
if err != nil || len(sig) != 65 {
|
||||
return nil, fmt.Errorf("tron: signature must be 65 bytes hex")
|
||||
}
|
||||
// Rebuild btcec compact layout: [header=27+recid || R || S].
|
||||
compact := make([]byte, 65)
|
||||
compact[0] = 27 + sig[64]
|
||||
copy(compact[1:], sig[:64])
|
||||
pub, _, err := ecdsa.RecoverCompact(compact, txid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tron: recover: %w", err)
|
||||
}
|
||||
return keccakAddressBody(pub.SerializeUncompressed()), nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/wallet"
|
||||
)
|
||||
|
||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// addr index 0 of the test mnemonic (see wallet golden vector).
|
||||
const testAddr0 = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH"
|
||||
|
||||
// TestSignRecoverRoundtrip proves the signing path is correct: signing raw_data
|
||||
// with address 0's private key yields a signature that recovers to address 0.
|
||||
// This is the crux of the money-moving path — if it holds, TRON will accept the
|
||||
// signature as coming from the owner.
|
||||
func TestSignRecoverRoundtrip(t *testing.T) {
|
||||
priv, err := wallet.PrivKeyHexFromMnemonic(testMnemonic, "", 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("privkey: %v", err)
|
||||
}
|
||||
rawHex := "0a0212340a0212341234567890abcdef" // arbitrary non-empty raw_data
|
||||
|
||||
sig, err := SignRawData(rawHex, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
if len(sig) != 130 { // 65 bytes == 130 hex chars
|
||||
t.Fatalf("signature hex len %d, want 130", len(sig))
|
||||
}
|
||||
|
||||
body, err := RecoverAddressBody(rawHex, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("recover: %v", err)
|
||||
}
|
||||
wantBody, _ := wallet.TronAddressBodyHex(testAddr0)
|
||||
if hex.EncodeToString(body) != wantBody {
|
||||
t.Fatalf("recovered body %x != address 0 body %s", body, wantBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABIEncodeTransferParams(t *testing.T) {
|
||||
p, err := ABIEncodeTransferParams(testAddr0, 5_000000)
|
||||
if err != nil {
|
||||
t.Fatalf("abi: %v", err)
|
||||
}
|
||||
if len(p) != 128 { // 64 bytes == 128 hex chars
|
||||
t.Fatalf("param hex len %d, want 128", len(p))
|
||||
}
|
||||
// bytes[12:32] must equal the 20-byte address body.
|
||||
wantBody, _ := wallet.TronAddressBodyHex(testAddr0)
|
||||
if p[24:64] != wantBody {
|
||||
t.Fatalf("recipient word %s != body %s", p[24:64], wantBody)
|
||||
}
|
||||
// bytes[0:12] must be zero padding.
|
||||
if p[0:24] != "000000000000000000000000" {
|
||||
t.Fatalf("recipient not left-padded: %s", p[0:24])
|
||||
}
|
||||
// amount word must decode to 5000000.
|
||||
amt, ok := new(big.Int).SetString(p[64:128], 16)
|
||||
if !ok || amt.Int64() != 5_000000 {
|
||||
t.Fatalf("amount word decodes to %v, want 5000000", amt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABIEncodeRejectsBad(t *testing.T) {
|
||||
if _, err := ABIEncodeTransferParams(testAddr0, 0); err == nil {
|
||||
t.Fatal("expected error on zero amount")
|
||||
}
|
||||
if _, err := ABIEncodeTransferParams("garbage", 1); err == nil {
|
||||
t.Fatal("expected error on bad address")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/wallet"
|
||||
)
|
||||
|
||||
// ⚠️ The online chain ops below (BuildTransfer / TRC20Balance / Broadcast) can
|
||||
// only be fully validated against a live TronGrid + real funds (Phase E). The
|
||||
// pure crypto (sign.go / abi.go / address.go) is unit-tested; these are not.
|
||||
|
||||
// UnsignedTx is the transaction object TronGrid returns from triggersmartcontract.
|
||||
// raw_data is kept verbatim so it round-trips unchanged into broadcast.
|
||||
type UnsignedTx struct {
|
||||
TxID string `json:"txID"`
|
||||
RawData json.RawMessage `json:"raw_data"`
|
||||
RawDataHex string `json:"raw_data_hex"`
|
||||
Visible bool `json:"visible"`
|
||||
}
|
||||
|
||||
// SignedTx is an UnsignedTx with the signature attached, ready to broadcast.
|
||||
type SignedTx struct {
|
||||
TxID string `json:"txID"`
|
||||
RawData json.RawMessage `json:"raw_data"`
|
||||
RawDataHex string `json:"raw_data_hex"`
|
||||
Visible bool `json:"visible"`
|
||||
Signature []string `json:"signature"`
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, path string, body, out any) error {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("tron: %s status %d", path, resp.StatusCode)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// BuildTransfer asks TronGrid to construct an unsigned TRC20 transfer of amount
|
||||
// (micro-USDT) from ownerAddr to toAddr. No private key involved — safe online.
|
||||
// feeLimit is in sun (1e-6 TRX); ~30 TRX (30_000_000) is a safe cap for a TRC20
|
||||
// transfer.
|
||||
func (c *Client) BuildTransfer(ctx context.Context, ownerAddr, toAddr string, amount, feeLimit int64) (*UnsignedTx, error) {
|
||||
ownerBody, err := wallet.DecodeTronAddress(ownerAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contractBody, err := wallet.DecodeTronAddress(c.usdtContract)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
param, err := ABIEncodeTransferParams(toAddr, amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody := map[string]any{
|
||||
"owner_address": hex.EncodeToString(ownerBody),
|
||||
"contract_address": hex.EncodeToString(contractBody),
|
||||
"function_selector": TransferSelector,
|
||||
"parameter": param,
|
||||
"fee_limit": feeLimit,
|
||||
"call_value": 0,
|
||||
"visible": false,
|
||||
}
|
||||
var resp struct {
|
||||
Transaction UnsignedTx `json:"transaction"`
|
||||
Result struct {
|
||||
Result bool `json:"result"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := c.postJSON(ctx, "/wallet/triggersmartcontract", reqBody, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Transaction.RawDataHex == "" {
|
||||
return nil, fmt.Errorf("tron: build transfer failed: %s %s", resp.Result.Code, decodeHexMessage(resp.Result.Message))
|
||||
}
|
||||
return &resp.Transaction, nil
|
||||
}
|
||||
|
||||
// TRC20Balance returns the address's USDT balance in micro-USDT.
|
||||
func (c *Client) TRC20Balance(ctx context.Context, addr string) (int64, error) {
|
||||
u := fmt.Sprintf("/v1/accounts/%s", addr)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+u, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("tron: account status %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data []struct {
|
||||
TRC20 []map[string]string `json:"trc20"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(body.Data) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
for _, m := range body.Data[0].TRC20 {
|
||||
if v, ok := m[c.usdtContract]; ok {
|
||||
return strconv.ParseInt(v, 10, 64)
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Broadcast submits a signed transaction. Returns the on-chain txid on success.
|
||||
func (c *Client) Broadcast(ctx context.Context, tx *SignedTx) (string, error) {
|
||||
var resp struct {
|
||||
Result bool `json:"result"`
|
||||
Txid string `json:"txid"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.postJSON(ctx, "/wallet/broadcasttransaction", tx, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !resp.Result {
|
||||
return "", fmt.Errorf("tron: broadcast rejected: %s %s", resp.Code, decodeHexMessage(resp.Message))
|
||||
}
|
||||
return resp.Txid, nil
|
||||
}
|
||||
|
||||
// decodeHexMessage best-effort decodes TronGrid's hex-encoded error messages.
|
||||
func decodeHexMessage(s string) string {
|
||||
if b, err := hex.DecodeString(s); err == nil && len(b) > 0 {
|
||||
return string(b)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/base58"
|
||||
)
|
||||
|
||||
// DecodeTronAddress decodes a base58check TRON address ("T...") to its 21-byte
|
||||
// payload (0x41 || 20-byte body), validating the checksum. The 20-byte body
|
||||
// (payload[1:]) is what TRON ABI parameters use (left-padded to 32 bytes).
|
||||
func DecodeTronAddress(addr string) ([]byte, error) {
|
||||
raw := base58.Decode(addr)
|
||||
if len(raw) != 25 { // 21 payload + 4 checksum
|
||||
return nil, fmt.Errorf("wallet: bad TRON address length %d", len(raw))
|
||||
}
|
||||
payload, sum := raw[:21], raw[21:]
|
||||
h1 := sha256.Sum256(payload)
|
||||
h2 := sha256.Sum256(h1[:])
|
||||
if !bytes.Equal(h2[:4], sum) {
|
||||
return nil, fmt.Errorf("wallet: bad TRON address checksum")
|
||||
}
|
||||
if payload[0] != tronAddrPrefix {
|
||||
return nil, fmt.Errorf("wallet: bad TRON address prefix 0x%02x", payload[0])
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// TronAddressBodyHex returns the 20-byte address body as hex (no 0x41 prefix) —
|
||||
// used to build/verify ABI-encoded transfer recipients.
|
||||
func TronAddressBodyHex(addr string) (string, error) {
|
||||
payload, err := DecodeTronAddress(addr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", payload[1:]), nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package wallet
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecodeTronAddressRoundtrip(t *testing.T) {
|
||||
// Golden addresses from the test mnemonic (see derive_test.go).
|
||||
for _, addr := range goldenAddrs {
|
||||
payload, err := DecodeTronAddress(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("decode %s: %v", addr, err)
|
||||
}
|
||||
if len(payload) != 21 || payload[0] != 0x41 {
|
||||
t.Fatalf("bad payload for %s: %x", addr, payload)
|
||||
}
|
||||
// Re-encode the payload and expect the same address back.
|
||||
if got := base58CheckEncode(payload); got != addr {
|
||||
t.Fatalf("roundtrip: %s -> %s", addr, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTronAddressRejectsBad(t *testing.T) {
|
||||
if _, err := DecodeTronAddress("TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdX"); err == nil {
|
||||
t.Fatal("expected checksum failure on tampered address")
|
||||
}
|
||||
if _, err := DecodeTronAddress("not-an-address"); err == nil {
|
||||
t.Fatal("expected failure on garbage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTronAddressBodyHex(t *testing.T) {
|
||||
body, err := TronAddressBodyHex(goldenAddrs[0])
|
||||
if err != nil {
|
||||
t.Fatalf("body hex: %v", err)
|
||||
}
|
||||
if len(body) != 40 { // 20 bytes -> 40 hex chars
|
||||
t.Fatalf("body hex len %d, want 40 (%s)", len(body), body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Package wallet derives TRON (TRC20) receiving addresses from a BIP32 account
|
||||
// extended public key (xpub) — watch-only, no private keys involved. The
|
||||
// pangolin-pay watcher uses AddressFromAccountXpub to assign a unique receiving
|
||||
// address per order (m/44'/195'/0'/0/i). Private-key material (seed.go) is for
|
||||
// OFFLINE use only (sweep signing / vector generation), never on the hot service.
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/base58"
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// tronAddrPrefix is the TRON mainnet address version byte (0x41). It is prepended
|
||||
// to the 20-byte address body before Base58Check encoding, yielding the familiar
|
||||
// "T..." addresses.
|
||||
const tronAddrPrefix = 0x41
|
||||
|
||||
// AddressFromAccountXpub derives the TRON address at m/…/<change>/<index> from an
|
||||
// account-level extended public key (e.g. the xpub of m/44'/195'/0'). It is
|
||||
// watch-only: an xpub can derive child addresses/public keys but never private
|
||||
// keys, so this is safe to run on an internet-facing service.
|
||||
//
|
||||
// change is 0 for the external (receiving) chain; index is the per-order address
|
||||
// index. Both are non-hardened, which is exactly why the account-level xpub can
|
||||
// derive them.
|
||||
func AddressFromAccountXpub(xpub string, change, index uint32) (string, error) {
|
||||
acct, err := hdkeychain.NewKeyFromString(xpub)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: parse xpub: %w", err)
|
||||
}
|
||||
if acct.IsPrivate() {
|
||||
return "", fmt.Errorf("wallet: expected an xpub (public extended key), got a private one")
|
||||
}
|
||||
chainKey, err := acct.Derive(change)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: derive change %d: %w", change, err)
|
||||
}
|
||||
addrKey, err := chainKey.Derive(index)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: derive index %d: %w", index, err)
|
||||
}
|
||||
pub, err := addrKey.ECPubKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: ec pubkey: %w", err)
|
||||
}
|
||||
return PubKeyToTronAddress(pub.SerializeUncompressed()), nil
|
||||
}
|
||||
|
||||
// PubKeyToTronAddress converts a 65-byte uncompressed secp256k1 public key
|
||||
// (0x04 || X || Y) to a TRON Base58Check address:
|
||||
//
|
||||
// body = 0x41 || keccak256(X||Y)[12:] // last 20 bytes of the Keccak hash
|
||||
// address = Base58( body || dsha256(body)[:4] )
|
||||
//
|
||||
// Note: TRON/Ethereum use *legacy* Keccak-256 (not the finalized SHA3-256).
|
||||
func PubKeyToTronAddress(uncompressed []byte) string {
|
||||
h := sha3.NewLegacyKeccak256()
|
||||
h.Write(uncompressed[1:]) // drop the 0x04 prefix; hash the 64-byte X||Y
|
||||
sum := h.Sum(nil)
|
||||
body := append([]byte{tronAddrPrefix}, sum[12:]...) // 0x41 + last 20 bytes
|
||||
return base58CheckEncode(body)
|
||||
}
|
||||
|
||||
// base58CheckEncode appends a 4-byte double-SHA256 checksum and Base58-encodes.
|
||||
// (TRON's version byte 0x41 is already inside input, so this is a plain
|
||||
// checksum-append, not btcutil's version-byte CheckEncode.)
|
||||
func base58CheckEncode(input []byte) string {
|
||||
first := sha256.Sum256(input)
|
||||
second := sha256.Sum256(first[:])
|
||||
full := make([]byte, 0, len(input)+4)
|
||||
full = append(full, input...)
|
||||
full = append(full, second[:4]...)
|
||||
return base58.Encode(full)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testMnemonic is the canonical all-zero-entropy BIP39 test vector. The derived
|
||||
// TRON addresses logged by TestAccountXpubDeriveConsistency must match
|
||||
// iancoleman.io/bip39 (Coin = TRX, BIP44) — that manual comparison is Phase A.4
|
||||
// of the crypto-tx-engine plan (guards against a derivation mismatch that would
|
||||
// silently send funds to addresses we don't control).
|
||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// TestAccountXpubDeriveConsistency proves the watcher's watch-only path
|
||||
// (xpub -> address) yields exactly the same address as the offline private path
|
||||
// (seed -> privkey -> pubkey -> address) for each index. That equality is what
|
||||
// guarantees every receiving address the watcher hands out is spendable by the
|
||||
// key we hold in cold storage.
|
||||
func TestAccountXpubDeriveConsistency(t *testing.T) {
|
||||
xpub, err := AccountXpubFromMnemonic(testMnemonic, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("account xpub: %v", err)
|
||||
}
|
||||
t.Logf("account xpub (m/44'/195'/0'): %s", xpub)
|
||||
|
||||
acct, err := AccountKeyFromMnemonic(testMnemonic, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("account key: %v", err)
|
||||
}
|
||||
|
||||
for i := uint32(0); i < 5; i++ {
|
||||
viaXpub, err := AddressFromAccountXpub(xpub, 0, i) // what the watcher does
|
||||
if err != nil {
|
||||
t.Fatalf("via xpub [%d]: %v", i, err)
|
||||
}
|
||||
|
||||
ck, err := acct.Derive(0)
|
||||
if err != nil {
|
||||
t.Fatalf("derive change: %v", err)
|
||||
}
|
||||
ak, err := ck.Derive(i)
|
||||
if err != nil {
|
||||
t.Fatalf("derive index %d: %v", i, err)
|
||||
}
|
||||
pub, err := ak.ECPubKey()
|
||||
if err != nil {
|
||||
t.Fatalf("ec pubkey: %v", err)
|
||||
}
|
||||
viaPriv := PubKeyToTronAddress(pub.SerializeUncompressed())
|
||||
|
||||
if viaXpub != viaPriv {
|
||||
t.Fatalf("index %d: xpub-derived %q != priv-derived %q", i, viaXpub, viaPriv)
|
||||
}
|
||||
if !strings.HasPrefix(viaXpub, "T") || len(viaXpub) != 34 {
|
||||
t.Fatalf("index %d: not a valid TRON address: %q", i, viaXpub)
|
||||
}
|
||||
t.Logf("m/44'/195'/0'/0/%d -> %s", i, viaXpub)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownVector locks the derivation to a golden result (filled from the run of
|
||||
// TestAccountXpubDeriveConsistency, then confirmed against iancoleman.io — A.4).
|
||||
// If this ever changes, the derivation implementation regressed.
|
||||
func TestKnownVector(t *testing.T) {
|
||||
if len(goldenAddrs) == 0 {
|
||||
t.Skip("golden vector not yet baked — run TestAccountXpubDeriveConsistency, confirm vs Ian Coleman, then fill goldenAddrs")
|
||||
}
|
||||
xpub, err := AccountXpubFromMnemonic(testMnemonic, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("xpub: %v", err)
|
||||
}
|
||||
if goldenXpub != "" && xpub != goldenXpub {
|
||||
t.Fatalf("account xpub changed:\n got %s\n want %s", xpub, goldenXpub)
|
||||
}
|
||||
for i, want := range goldenAddrs {
|
||||
got, err := AddressFromAccountXpub(xpub, 0, uint32(i))
|
||||
if err != nil {
|
||||
t.Fatalf("addr[%d]: %v", i, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("addr[%d]: got %s want %s", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Golden vector for the "abandon…about" test mnemonic, Coin=TRX, m/44'/195'/0'.
|
||||
// Locks the derivation against regression. ⚠️ MUST be confirmed once against
|
||||
// iancoleman.io/bip39 (Phase A.4) — self-consistency (TestAccountXpubDeriveConsistency)
|
||||
// proves the xpub and private paths agree, but only an independent tool proves
|
||||
// both aren't wrong the same way. If Ian Coleman disagrees, the impl has a bug.
|
||||
var (
|
||||
goldenXpub = "xpub6D1AabNHCupeiLM65ZR9UStMhJ1vCpyV4XbZdyhMZBiJXALQtmn9p42VTQckoHVn8WNqS7dqnJokZHAHcHGoaQgmv8D45oNUKx6DZMNZBCd"
|
||||
goldenAddrs = []string{
|
||||
"TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", // m/44'/195'/0'/0/0
|
||||
"TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK", // m/44'/195'/0'/0/1
|
||||
"TYJPRrdB5APNeRs4R7fYZSwW3TcrTKw2gx", // m/44'/195'/0'/0/2
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
bip39 "github.com/tyler-smith/go-bip39"
|
||||
)
|
||||
|
||||
// ⚠️ OFFLINE ONLY. Everything in this file touches the BIP39 seed / private keys.
|
||||
// It exists for (a) generating the account xpub to hand to the watcher, and
|
||||
// (b) deriving per-address private keys for offline sweep signing (Phase D).
|
||||
// It must NEVER be linked into or run on the internet-facing pangolin-pay
|
||||
// watcher — the hot service only ever handles the account xpub (see derive.go).
|
||||
|
||||
const (
|
||||
purposeBIP44 = 44
|
||||
coinTypeTRON = 195
|
||||
// hardenedOffset marks a derivation index as hardened (requires the private
|
||||
// key). BIP44's first three levels (purpose'/coin'/account') are hardened.
|
||||
hardenedOffset = hdkeychain.HardenedKeyStart // 0x80000000
|
||||
)
|
||||
|
||||
// AccountKeyFromMnemonic derives the account-level extended *private* key at
|
||||
// m/44'/195'/<account>' from a BIP39 mnemonic (+ optional passphrase).
|
||||
// OFFLINE ONLY.
|
||||
func AccountKeyFromMnemonic(mnemonic, passphrase string, account uint32) (*hdkeychain.ExtendedKey, error) {
|
||||
if !bip39.IsMnemonicValid(mnemonic) {
|
||||
return nil, fmt.Errorf("wallet: invalid BIP39 mnemonic (checksum/wordlist)")
|
||||
}
|
||||
seed := bip39.NewSeed(mnemonic, passphrase)
|
||||
master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wallet: master key: %w", err)
|
||||
}
|
||||
for _, step := range []uint32{
|
||||
hardenedOffset + purposeBIP44,
|
||||
hardenedOffset + coinTypeTRON,
|
||||
hardenedOffset + account,
|
||||
} {
|
||||
master, err = master.Derive(step)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wallet: derive account path: %w", err)
|
||||
}
|
||||
}
|
||||
return master, nil
|
||||
}
|
||||
|
||||
// AccountXpubFromMnemonic returns the account-level xpub string to hand to the
|
||||
// watcher. OFFLINE ONLY — run this once on the air-gapped machine, copy only the
|
||||
// returned xpub to the hot service.
|
||||
func AccountXpubFromMnemonic(mnemonic, passphrase string, account uint32) (string, error) {
|
||||
k, err := AccountKeyFromMnemonic(mnemonic, passphrase, account)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pub, err := k.Neuter() // strip the private key -> xpub
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: neuter: %w", err)
|
||||
}
|
||||
return pub.String(), nil
|
||||
}
|
||||
|
||||
// PrivKeyHexFromMnemonic derives the raw secp256k1 private key (hex) for the
|
||||
// address at m/44'/195'/<account>'/<change>/<index>, for offline sweep signing.
|
||||
// OFFLINE ONLY.
|
||||
func PrivKeyHexFromMnemonic(mnemonic, passphrase string, account, change, index uint32) (string, error) {
|
||||
acct, err := AccountKeyFromMnemonic(mnemonic, passphrase, account)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
chainKey, err := acct.Derive(change)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: derive change: %w", err)
|
||||
}
|
||||
addrKey, err := chainKey.Derive(index)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: derive index: %w", err)
|
||||
}
|
||||
priv, err := addrKey.ECPrivKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: ec privkey: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", priv.Serialize()), nil
|
||||
}
|
||||
|
||||
// AddressFromMnemonic derives the TRON address at m/44'/195'/<account>'/<change>/<index>
|
||||
// straight from the mnemonic. OFFLINE ONLY — used by the sweep signer to verify
|
||||
// that a derived key matches the address it is about to sign for.
|
||||
func AddressFromMnemonic(mnemonic, passphrase string, account, change, index uint32) (string, error) {
|
||||
acct, err := AccountKeyFromMnemonic(mnemonic, passphrase, account)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
chainKey, err := acct.Derive(change)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: derive change: %w", err)
|
||||
}
|
||||
addrKey, err := chainKey.Derive(index)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: derive index: %w", err)
|
||||
}
|
||||
pub, err := addrKey.ECPubKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: ec pubkey: %w", err)
|
||||
}
|
||||
return PubKeyToTronAddress(pub.SerializeUncompressed()), nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package watcher polls TronGrid for incoming USDT and marks paid orders.
|
||||
// It only reads the chain and flips order state — it never holds keys or moves
|
||||
// funds (sweeping is a separate offline step).
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
"github.com/wangjia/pangolin/pay/internal/tron"
|
||||
)
|
||||
|
||||
type Watcher struct {
|
||||
st *store.Store
|
||||
tron tron.Fetcher
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(st *store.Store, f tron.Fetcher, log *slog.Logger) *Watcher {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Watcher{st: st, tron: f, log: log, now: time.Now}
|
||||
}
|
||||
|
||||
// Tick: (1) expire overdue pending orders; (2) for each still-pending order,
|
||||
// look for a confirmed incoming transfer >= the expected amount on its unique
|
||||
// address and mark it paid. Idempotent — a transfer seen twice flips the order
|
||||
// at most once (MarkPaid only affects a still-pending row).
|
||||
func (w *Watcher) Tick(ctx context.Context) error {
|
||||
if n, err := w.st.MarkExpired(ctx, w.now()); err != nil {
|
||||
return err
|
||||
} else if n > 0 {
|
||||
w.log.Info("orders expired", "count", n)
|
||||
}
|
||||
|
||||
pending, err := w.st.ListPending(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, o := range pending {
|
||||
transfers, err := w.tron.IncomingTransfers(ctx, o.Address)
|
||||
if err != nil {
|
||||
// Transient (rate limit / network): log and move on; retried next tick.
|
||||
w.log.Warn("fetch transfers failed", "order", o.OrderNo, "err", err)
|
||||
continue
|
||||
}
|
||||
for _, t := range transfers {
|
||||
if t.Value < o.ExpectAmount {
|
||||
continue
|
||||
}
|
||||
ok, err := w.st.MarkPaid(ctx, o.OrderNo, t.TxID)
|
||||
if err != nil {
|
||||
w.log.Error("mark paid", "order", o.OrderNo, "err", err)
|
||||
break
|
||||
}
|
||||
if ok {
|
||||
w.log.Info("order paid", "order", o.OrderNo, "tx", t.TxID, "value", t.Value, "address", o.Address)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Loop runs Tick every interval until ctx is cancelled.
|
||||
func (w *Watcher) Loop(ctx context.Context, interval time.Duration) {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := w.Tick(ctx); err != nil {
|
||||
w.log.Error("watcher tick", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
"github.com/wangjia/pangolin/pay/internal/tron"
|
||||
)
|
||||
|
||||
type mockFetcher struct{ m map[string][]tron.Transfer }
|
||||
|
||||
func (f *mockFetcher) IncomingTransfers(_ context.Context, addr string) ([]tron.Transfer, error) {
|
||||
return f.m[addr], nil
|
||||
}
|
||||
|
||||
func newPending(t *testing.T, st *store.Store, orderNo, addr string, amount int64, expires time.Time) {
|
||||
t.Helper()
|
||||
o := &store.Order{
|
||||
OrderNo: orderNo, SKU: "pro", ExpectAmount: amount, Address: addr,
|
||||
Status: store.StatusPending, CreatedAt: time.Unix(1_700_000_000, 0), ExpiresAt: expires,
|
||||
}
|
||||
if err := st.CreateOrder(context.Background(), o); err != nil {
|
||||
t.Fatalf("seed order: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcherMarksPaidOnSufficientTransfer(t *testing.T) {
|
||||
st, _ := store.Open(":memory:")
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_100, 0)
|
||||
|
||||
newPending(t, st, "PAY1", "TADDR1", 5_000000, now.Add(time.Hour))
|
||||
fetch := &mockFetcher{m: map[string][]tron.Transfer{}}
|
||||
w := New(st, fetch, nil)
|
||||
w.now = func() time.Time { return now }
|
||||
|
||||
// No transfer yet -> stays pending.
|
||||
if err := w.Tick(ctx); err != nil {
|
||||
t.Fatalf("tick1: %v", err)
|
||||
}
|
||||
if o, _ := st.GetOrder(ctx, "PAY1"); o.Status != store.StatusPending {
|
||||
t.Fatalf("want pending, got %s", o.Status)
|
||||
}
|
||||
|
||||
// Underpayment -> still pending.
|
||||
fetch.m["TADDR1"] = []tron.Transfer{{TxID: "tx-under", To: "TADDR1", Value: 4_000000}}
|
||||
_ = w.Tick(ctx)
|
||||
if o, _ := st.GetOrder(ctx, "PAY1"); o.Status != store.StatusPending {
|
||||
t.Fatalf("underpay should stay pending, got %s", o.Status)
|
||||
}
|
||||
|
||||
// Sufficient payment -> paid, tx recorded.
|
||||
fetch.m["TADDR1"] = []tron.Transfer{{TxID: "tx-ok", To: "TADDR1", Value: 5_000000}}
|
||||
_ = w.Tick(ctx)
|
||||
o, _ := st.GetOrder(ctx, "PAY1")
|
||||
if o.Status != store.StatusPaid || o.TxID != "tx-ok" {
|
||||
t.Fatalf("want paid/tx-ok, got %s/%s", o.Status, o.TxID)
|
||||
}
|
||||
|
||||
// Idempotent: another tick with same transfer doesn't error or flip anything.
|
||||
if err := w.Tick(ctx); err != nil {
|
||||
t.Fatalf("idempotent tick: %v", err)
|
||||
}
|
||||
o, _ = st.GetOrder(ctx, "PAY1")
|
||||
if o.Status != store.StatusPaid || o.TxID != "tx-ok" {
|
||||
t.Fatalf("idempotency broken: %s/%s", o.Status, o.TxID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcherExpiresOverdue(t *testing.T) {
|
||||
st, _ := store.Open(":memory:")
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_100, 0)
|
||||
|
||||
newPending(t, st, "OLD", "TADDR2", 1_000000, now.Add(-time.Minute)) // already overdue
|
||||
w := New(st, &mockFetcher{m: map[string][]tron.Transfer{}}, nil)
|
||||
w.now = func() time.Time { return now }
|
||||
|
||||
if err := w.Tick(ctx); err != nil {
|
||||
t.Fatalf("tick: %v", err)
|
||||
}
|
||||
if o, _ := st.GetOrder(ctx, "OLD"); o.Status != store.StatusExpired {
|
||||
t.Fatalf("want expired, got %s", o.Status)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<html lang="en" data-theme="light" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* 首屏渲染前据 localStorage 设好 data-theme/lang,避免明暗主题(整页配色)在
|
||||
挂载后才应用造成的 FOUC 闪烁。/user/* CSP 允许 'unsafe-inline' 脚本,故直接内联。
|
||||
与官网 Site.astro 的 no-FOUC 脚本同法(键名用用户中心的 pg_uc_theme/pg_uc_lang)。 */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var t=localStorage.getItem('pg_uc_theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t;var l=localStorage.getItem('pg_uc_lang');if(l)document.documentElement.lang=l;}catch(e){}})()",
|
||||
}}
|
||||
/>
|
||||
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
|
||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
||||
<link rel="stylesheet" href={`${BASE_PATH}/colors_and_type.css`} />
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function Invite({ t }: { t: TFn }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('inviteTitle')}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--fg3)', marginTop: 5, lineHeight: 1.6 }}>{t('inviteSub')}</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function Login({ onDone }: { onDone: () => void }) {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Mark size={32} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)', lineHeight: 1 }}>穿山甲</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)', lineHeight: 1 }}>{t('brandName')}</div>
|
||||
<div style={{ fontSize: 8.5, fontWeight: 600, letterSpacing: '0.2em', color: 'var(--accent)', marginTop: 3 }}>PANGOLIN</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function Overview({
|
||||
const free = me.plan === 'free';
|
||||
const vals = me.weeklyGB;
|
||||
const max = Math.max(...vals, 0.1);
|
||||
const labels = lang === 'zh' ? ['一', '二', '三', '四', '五', '六', '日'] : ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
||||
const labels = lang === 'zh' ? ['一', '二', '三', '四', '五', '六', '日'] : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
const stats: [string, string, string, string][] = [
|
||||
['clock', t('quotaToday'), free ? String(me.quotaTodayMin ?? 0) : '∞', free ? 'min' : ''],
|
||||
@@ -76,13 +76,13 @@ export default function Overview({
|
||||
</div>
|
||||
|
||||
{/* usage chart + quick actions */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1.6fr 1fr', gap: 14, alignItems: 'start' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1.6fr 1fr', gap: 14, alignItems: 'stretch' }}>
|
||||
<div style={{ ...card, padding: '18px 22px' }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)', marginBottom: 16 }}>{t('usageTitle')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 14, height: 110 }}>
|
||||
{vals.map((v, i) => (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9.5, color: 'var(--fg3)' }}>{v}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9.5, color: 'var(--fg3)' }}>{Number(v.toPrecision(2))}</div>
|
||||
<div style={{ width: '100%', maxWidth: 30, height: `${(v / max) * 76}px`, background: 'var(--accent)', borderRadius: '5px 5px 0 0', opacity: 0.85 }} />
|
||||
<div style={{ fontSize: 10.5, color: 'var(--fg3)' }}>{labels[i]}</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function Redeem({ t, lang, onRedeemed }: { t: TFn; lang: Lang; on
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('navRedeem')}</div>
|
||||
<div style={{ ...card, padding: '20px 22px' }}>
|
||||
<div style={{ fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)', marginBottom: 12 }}>{t('redeemTitle')}</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Icon } from './icons';
|
||||
import { card, input } from './shared';
|
||||
import { ErrorLine } from './Login';
|
||||
import { useUI } from '../lib/theme';
|
||||
import type { TFn, Lang } from '../lib/i18n';
|
||||
import { getClient } from '../lib/api/client';
|
||||
import { bilingual } from '../lib/api/errors';
|
||||
@@ -13,9 +12,8 @@ import type { Device, TotpSetup } from '../lib/api/types';
|
||||
|
||||
export default function Settings({ t, lang, totpEnabled, onTotpChange }: { t: TFn; lang: Lang; totpEnabled: boolean; onTotpChange: () => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('settingsTitle')}</div>
|
||||
<Preferences t={t} />
|
||||
<TotpSection t={t} lang={lang} enabled={totpEnabled} onChange={onTotpChange} />
|
||||
<Devices t={t} lang={lang} />
|
||||
</div>
|
||||
@@ -24,57 +22,6 @@ export default function Settings({ t, lang, totpEnabled, onTotpChange }: { t: TF
|
||||
|
||||
const sectionTitle: React.CSSProperties = { fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)' };
|
||||
|
||||
function Preferences({ t }: { t: TFn }) {
|
||||
const { lang, setLang, theme, setTheme } = useUI();
|
||||
return (
|
||||
<div style={{ ...card, padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={sectionTitle}>{t('prefTitle')}</div>
|
||||
<Row label={t('prefLang')}>
|
||||
<Seg
|
||||
value={lang}
|
||||
options={[['zh', '中文'], ['en', 'EN']]}
|
||||
onPick={(v) => setLang(v as Lang)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label={t('prefTheme')}>
|
||||
<Seg
|
||||
value={theme}
|
||||
options={[['light', t('themeLight')], ['dark', t('themeDark')]]}
|
||||
icons={{ light: 'sun', dark: 'moon' }}
|
||||
onPick={(v) => setTheme(v as 'light' | 'dark')}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg2)' }}>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Seg({ value, options, onPick, icons }: { value: string; options: [string, string][]; onPick: (v: string) => void; icons?: Record<string, string> }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', background: 'var(--bg-subtle)', borderRadius: 999, padding: 3, gap: 2 }}>
|
||||
{options.map(([v, l]) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => onPick(v)}
|
||||
aria-pressed={value === v}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 'none', cursor: 'pointer', borderRadius: 999, padding: '6px 14px', fontFamily: 'var(--font-sans)', fontSize: 12.5, fontWeight: 700, background: value === v ? 'var(--accent)' : 'transparent', color: value === v ? 'var(--fg-on-accent)' : 'var(--fg3)' }}
|
||||
>
|
||||
{icons && <Icon name={icons[v]} size={14} color={value === v ? 'var(--fg-on-accent)' : 'var(--fg3)'} />}
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── TOTP 2FA ───────── */
|
||||
function TotpSection({ t, lang, enabled, onChange }: { t: TFn; lang: Lang; enabled: boolean; onChange: () => void }) {
|
||||
const api = getClient();
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function Subscription({ t, mobile }: { t: TFn; mobile: boolean })
|
||||
}, [api]);
|
||||
|
||||
const clients = [
|
||||
{ name: '穿山甲 App', sub: 'iOS / Android / 桌面', icon: 'shield-check', accent: true },
|
||||
{ name: `${t('brandName')} App`, sub: 'iOS / Android / 桌面', icon: 'shield-check', accent: true },
|
||||
{ name: 'Shadowrocket', sub: 'iOS', icon: 'external-link' },
|
||||
{ name: 'Clash Verge', sub: 'Windows / macOS', icon: 'external-link' },
|
||||
{ name: 'v2rayN', sub: 'Windows', icon: 'external-link' },
|
||||
@@ -48,7 +48,7 @@ export default function Subscription({ t, mobile }: { t: TFn; mobile: boolean })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 720 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('subTitle')}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--fg3)', marginTop: 5, lineHeight: 1.6 }}>{t('subDesc')}</div>
|
||||
|
||||
@@ -12,12 +12,20 @@ import Settings from './Settings';
|
||||
import { useUI } from '../lib/theme';
|
||||
import { makeT } from '../lib/i18n';
|
||||
import { apiMode, getClient } from '../lib/api/client';
|
||||
import { hasRefresh } from '../lib/api/session';
|
||||
import { hasRefresh, clearSession } from '../lib/api/session';
|
||||
import type { Me } from '../lib/api/types';
|
||||
|
||||
type View = 'overview' | 'sub' | 'redeem' | 'invite' | 'settings';
|
||||
const ORDER: View[] = ['overview', 'sub', 'redeem', 'invite', 'settings'];
|
||||
|
||||
/** redirect 白名单:仅接受单个 '/' 开头、且不以 '//' 或反斜杠开头的本站相对
|
||||
* 路径(防 open redirect,与 app/sso/page.tsx::safeRedirect 一致);否则返回 null。 */
|
||||
function safeRedirect(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
if (raw.charAt(0) !== '/' || raw.charAt(1) === '/' || raw.indexOf('\\') >= 0) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function useIsMobile() {
|
||||
const [m, setM] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -57,6 +65,10 @@ export default function UserCenter() {
|
||||
await api.refresh();
|
||||
if (alive) setAuthed(true);
|
||||
} catch {
|
||||
// 续期失败(refresh token 过期/被撤销/Redis 丢 JTI):清掉本地会话,
|
||||
// 否则 pg_uc_refresh 残留 → 官网仍显示"用户中心" → 点进来又续期失败 →
|
||||
// 来回刷登录页。清了官网会回到"Log in",状态一致。
|
||||
clearSession();
|
||||
if (alive) setAuthed(false);
|
||||
}
|
||||
}
|
||||
@@ -100,10 +112,16 @@ export default function UserCenter() {
|
||||
setView('overview');
|
||||
}
|
||||
|
||||
// 静态导出无服务端会话:首屏(!ready)与未登录一律直接渲染登录页,避免出现空白
|
||||
// 背景(慢网络下用户会看到"空的")。已登录用户(有 refresh)会话续期完成后再切面板。
|
||||
if (!ready || !authed) {
|
||||
return <Login onDone={() => { setAuthed(true); setView('overview'); }} />;
|
||||
// 登录成功回调:若 URL 带合法 ?redirect=<本站相对路径>(如官网带 ?redirect=/ 过来),
|
||||
// 回跳来源页;否则进用户中心概览。
|
||||
function onLoginDone() {
|
||||
const redirect = safeRedirect(new URLSearchParams(window.location.search).get('redirect'));
|
||||
if (redirect) {
|
||||
window.location.replace(redirect);
|
||||
return;
|
||||
}
|
||||
setAuthed(true);
|
||||
setView('overview');
|
||||
}
|
||||
|
||||
const nav: [View, string, string][] = [
|
||||
@@ -141,14 +159,17 @@ export default function UserCenter() {
|
||||
</button>
|
||||
));
|
||||
|
||||
return (
|
||||
// 应用外壳(顶栏 + 导航 + 内容区);content 由调用方决定 —— 已就绪传真实视图,
|
||||
// 引导期传加载占位。关键:外壳在「引导中(乐观)」与「已登录」两态都渲染,导航/顶栏
|
||||
// 始终在位,只有内容区替换 → 刷新时不再"整页重绘",消除"刷两次"的观感。
|
||||
const appShell = (content: React.ReactNode) => (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg)', fontFamily: 'var(--font-sans)' }}>
|
||||
{/* top bar */}
|
||||
<div style={{ position: 'sticky', top: 0, zIndex: 10, background: 'color-mix(in srgb, var(--bg) 85%, transparent)', backdropFilter: 'blur(12px)', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ maxWidth: 1000, margin: '0 auto', padding: mobile ? '0 16px' : '0 24px', height: mobile ? 54 : 60, display: 'flex', alignItems: 'center', gap: mobile ? 12 : 22 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<Mark size={26} />
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 16.5, color: 'var(--fg1)' }}>穿山甲</span>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 16.5, color: 'var(--fg1)' }}>{t('brandName')}</span>
|
||||
</div>
|
||||
{!mobile && <nav style={{ display: 'flex', gap: 4, flex: 1 }}>{navBtns}</nav>}
|
||||
{mobile && <div style={{ flex: 1 }} />}
|
||||
@@ -160,7 +181,7 @@ export default function UserCenter() {
|
||||
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} color="var(--fg3)" />
|
||||
</button>
|
||||
<LangSeg lang={lang} setLang={setLang} />
|
||||
<button onClick={signOut} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg2)', fontSize: 13, fontWeight: 600, padding: 6 }}>
|
||||
<button onClick={signOut} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg2)', fontSize: 13, fontWeight: 600, padding: 6, whiteSpace: 'nowrap' }}>
|
||||
<Icon name="log-out" size={15} color="var(--fg3)" />
|
||||
{!mobile && t('signOut')}
|
||||
</button>
|
||||
@@ -173,9 +194,20 @@ export default function UserCenter() {
|
||||
style={{ maxWidth: 1000, margin: '0 auto', padding: mobile ? '20px 16px 40px' : '30px 24px 48px' }}
|
||||
>
|
||||
<div key={view} style={{ animation: dir !== 0 ? `uc-in-${dir === 1 ? 'l' : 'r'} 200ms var(--ease-out)` : 'none' }}>
|
||||
{main}
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const spinner = <div style={{ minHeight: '40vh', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--fg3)', fontSize: 14 }}>{t('loading')}</div>;
|
||||
|
||||
// 引导(会话续期)期间一律渲染**应用外壳骨架**(顶栏/导航 + 内容区加载态)。关键:
|
||||
// · SSR 与客户端首帧渲染同一份外壳(不在 render 期读 localStorage/hasRefresh)→ 无水合
|
||||
// 不一致,首帧(静态 HTML)即外壳。
|
||||
// · 引导完成后:已登录→外壳 + 真实视图(**只换内容区**,顶栏/导航原地不动),未登录→登录页。
|
||||
// 于是刷新时不再"裸屏 spinner → 外壳 → 内容"多段重绘,只有内容区一次替换。
|
||||
if (!ready) return appShell(spinner);
|
||||
if (!authed) return <Login onDone={onLoginDone} />;
|
||||
return appShell(main);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
clearSession,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
setEmail,
|
||||
setSession,
|
||||
} from './session';
|
||||
|
||||
@@ -160,18 +161,25 @@ export class HttpClient implements ApiClient {
|
||||
async refresh(): Promise<Session> {
|
||||
const rt = getRefreshToken();
|
||||
if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' });
|
||||
// 服务端 /v1/auth/refresh 从 **请求体** {refresh_token} 读取(与原生 Flutter 客户端
|
||||
// auth_api.dart 一致);此处曾误用 X-Refresh-Token 头(仅 /auth/logout 读头),导致
|
||||
// 服务端拿到空 token → 400 invalid_request → 每次刷新页面即被登出。改回 body。
|
||||
const r = await this.request<RawTokenPair>('/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
allowRefresh: false,
|
||||
refreshToken: rt,
|
||||
body: { refresh_token: rt },
|
||||
});
|
||||
const session = mapSession(r);
|
||||
setSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
getMe = async (): Promise<Me> => mapMe(await this.request<RawMe>('/v1/me'));
|
||||
getMe = async (): Promise<Me> => {
|
||||
const me = mapMe(await this.request<RawMe>('/v1/me'));
|
||||
setEmail(me.email); // 同源官网读取显示用户名;clearSession/logout 时删除
|
||||
return me;
|
||||
};
|
||||
getSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription');
|
||||
resetSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription/reset', { method: 'POST' });
|
||||
listDevices = async (): Promise<Device[]> => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SubscriptionInfo,
|
||||
TotpSetup,
|
||||
} from './types';
|
||||
import { setSession, clearSession } from './session';
|
||||
import { setSession, clearSession, setEmail } from './session';
|
||||
|
||||
const delay = (ms = 420) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -88,6 +88,7 @@ export class MockClient implements ApiClient {
|
||||
|
||||
async getMe(): Promise<Me> {
|
||||
await delay(260);
|
||||
setEmail('me@pangolin.vpn'); // 同源官网读取显示用户名;clearSession/logout 时删除
|
||||
return {
|
||||
email: 'me@pangolin.vpn',
|
||||
plan: 'pro',
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
import type { Session } from './types';
|
||||
|
||||
const REFRESH_KEY = 'pg_uc_refresh';
|
||||
// 登录用户邮箱:同源官网(pangolin website)读取以显示用户名。仅邮箱、非敏感凭证。
|
||||
const EMAIL_KEY = 'pg_uc_email';
|
||||
|
||||
export function setEmail(email: string): void {
|
||||
if (typeof window === 'undefined' || !email) return;
|
||||
try {
|
||||
window.localStorage.setItem(EMAIL_KEY, email);
|
||||
} catch {
|
||||
/* ignore quota / privacy mode */
|
||||
}
|
||||
}
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let accessExpiresAt = 0;
|
||||
@@ -44,6 +55,7 @@ export function clearSession(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.removeItem(REFRESH_KEY);
|
||||
window.localStorage.removeItem(EMAIL_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const STRINGS: Record<string, Entry> = {
|
||||
navSettings: { zh: '设置', en: 'Settings', ja: '設定', ko: '설정', ru: 'Настройки', es: 'Ajustes' },
|
||||
signOut: { zh: '退出', en: 'Sign out', ja: 'ログアウト', ko: '로그아웃', ru: 'Выйти', es: 'Cerrar sesión' },
|
||||
backHome: { zh: '返回主页', en: 'Home', ja: 'ホーム', ko: '홈', ru: 'На главную', es: 'Inicio' },
|
||||
brandName: { zh: '穿山甲', en: 'Pangolin', ja: 'Pangolin', ko: 'Pangolin', ru: 'Pangolin', es: 'Pangolin' },
|
||||
|
||||
/* login */
|
||||
loginTitle: { zh: '登录用户中心', en: 'Log in to your account', ja: 'アカウントにログイン', ko: '계정에 로그인', ru: 'Вход в аккаунт', es: 'Inicia sesión en tu cuenta' },
|
||||
|
||||
@@ -27,7 +27,9 @@ export function UIProvider({ children }: { children: React.ReactNode }) {
|
||||
const l = window.localStorage.getItem(LANG_KEY) as Lang | null;
|
||||
const t = window.localStorage.getItem(THEME_KEY) as Theme | null;
|
||||
if (l && (['zh', 'en', 'ja', 'ko', 'ru', 'es'] as Lang[]).includes(l)) setLangState(l);
|
||||
const initial: Theme = t === 'dark' || t === 'light' ? t : window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
// 默认恒浅色:只有用户手动切换过(localStorage 有显式偏好)才用保存值,
|
||||
// 不跟随系统 prefers-color-scheme(避免系统暗色把登录页/用户中心染黑)。
|
||||
const initial: Theme = t === 'dark' || t === 'light' ? t : 'light';
|
||||
setThemeState(initial);
|
||||
} catch {
|
||||
/* ignore */
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
import type { T, Lang } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
interface Props { t: T; lang: Lang }
|
||||
const { t, lang } = Astro.props;
|
||||
|
||||
// 中文走 /zh/docs/*,其余语言暂共用英文文档(/docs/*)。
|
||||
const base = lang === 'zh' ? '/zh/docs' : '/docs';
|
||||
|
||||
const docs = [
|
||||
{ icon: 'rocket', t: 'docs.1t', d: 'docs.1d' },
|
||||
{ icon: 'circle-help', t: 'docs.2t', d: 'docs.2d' },
|
||||
{ icon: 'shield-check', t: 'docs.3t', d: 'docs.3d' },
|
||||
{ icon: 'lock', t: 'docs.4t', d: 'docs.4d' },
|
||||
{ icon: 'rocket', t: 'docs.1t', d: 'docs.1d', slug: 'quickstart' },
|
||||
{ icon: 'circle-help', t: 'docs.2t', d: 'docs.2d', slug: 'faq' },
|
||||
{ icon: 'shield-check', t: 'docs.3t', d: 'docs.3d', slug: 'protocol' },
|
||||
{ icon: 'lock', t: 'docs.4t', d: 'docs.4d', slug: 'privacy' },
|
||||
];
|
||||
---
|
||||
<section id="docs">
|
||||
@@ -20,13 +23,13 @@ const docs = [
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="docs-grid">
|
||||
{/* 文档页未就绪:卡片暂作信息展示(非链接),写好真实文档后改回 <a href> + 恢复「阅读」。 */}
|
||||
{docs.map((d) => (
|
||||
<div class="doc">
|
||||
<a class="doc" href={`${base}/${d.slug}/`}>
|
||||
<div class="ico"><Icon name={d.icon} /></div>
|
||||
<h3>{t(d.t)}</h3>
|
||||
<p>{t(d.d)}</p>
|
||||
</div>
|
||||
<span class="ln">{t('docs.read')}<Icon name="arrow-right" /></span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ const { t } = Astro.props;
|
||||
<div>
|
||||
<div class="nm">
|
||||
<Brand variant="footer" size={28} />
|
||||
穿山甲
|
||||
{t('nav.brand')}
|
||||
</div>
|
||||
<p class="tag">{t('ft.tag')}</p>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Download, Menu } from 'lucide-react';
|
||||
import { Download, Menu, Sun, Moon } from 'lucide-react';
|
||||
import { SITE } from '../config/site';
|
||||
|
||||
function Mark() {
|
||||
@@ -30,18 +30,57 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
const [userOpen, setUserOpen] = useState(false);
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
// 明/暗主题:默认浅色(不跟随系统),持久化 localStorage key pg_site_theme。
|
||||
const [theme, setTheme] = useState('light');
|
||||
const langRef = useRef(null);
|
||||
const userRef = useRef(null);
|
||||
|
||||
// 与用户中心同源:登录后 localStorage 存 pg_uc_refresh → 显示「用户中心」入口。
|
||||
// 登录后回跳主页:用户中心带 ?redirect=/(配合 usercenter 登录成功后回跳)。
|
||||
const loginHref = `${SITE.usercenter}?redirect=/`;
|
||||
// 用户名截断显示:优先邮箱 @ 前部分,缺失时回退通用词。
|
||||
const displayName = (email && email.split('@')[0]) || 'Account';
|
||||
|
||||
// 与用户中心同源:登录后 localStorage 存 pg_uc_refresh(+ pg_uc_email)→ 显示用户名下拉。
|
||||
useEffect(() => {
|
||||
try {
|
||||
setLoggedIn(!!localStorage.getItem('pg_uc_refresh'));
|
||||
setEmail(localStorage.getItem('pg_uc_email') || '');
|
||||
} catch {
|
||||
setLoggedIn(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 清登录态并跳转(切换用户 = 回登录页;退出 = 回主页)。
|
||||
const clearSession = () => {
|
||||
try {
|
||||
localStorage.removeItem('pg_uc_refresh');
|
||||
localStorage.removeItem('pg_uc_email');
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
const onSwitch = () => { clearSession(); window.location.href = loginHref; };
|
||||
const onLogout = () => { clearSession(); window.location.href = '/'; };
|
||||
|
||||
// 挂载时读 localStorage 同步按钮态(首屏无 FOUC 脚本已在 <head> 设好 data-theme)。
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('pg_site_theme');
|
||||
setTheme(saved === 'dark' ? 'dark' : 'light');
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
// 切换主题:写 <html data-theme> + localStorage,图标随态变。
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => {
|
||||
const next = prev === 'dark' ? 'light' : 'dark';
|
||||
try { localStorage.setItem('pg_site_theme', next); } catch { /* ignore */ }
|
||||
document.documentElement.dataset.theme = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!langOpen) return;
|
||||
const onDoc = (e) => { if (langRef.current && !langRef.current.contains(e.target)) setLangOpen(false); };
|
||||
@@ -51,6 +90,15 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };
|
||||
}, [langOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userOpen) return;
|
||||
const onDoc = (e) => { if (userRef.current && !userRef.current.contains(e.target)) setUserOpen(false); };
|
||||
const onKey = (e) => { if (e.key === 'Escape') setUserOpen(false); };
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };
|
||||
}, [userOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
onScroll();
|
||||
@@ -77,7 +125,7 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
<div class="wrap row">
|
||||
<a class="brand" href="#top">
|
||||
<Mark />
|
||||
<span class="nm">穿山甲</span>
|
||||
<span class="nm">{t.brand || 'Pangolin'}</span>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
{nav.map(([href, label]) => (
|
||||
@@ -85,6 +133,14 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
))}
|
||||
</nav>
|
||||
<div class="right">
|
||||
<button
|
||||
type="button"
|
||||
class="themetoggle"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
>
|
||||
{theme === 'dark' ? <Sun /> : <Moon />}
|
||||
</button>
|
||||
<div ref={langRef} class="langwrap">
|
||||
<button
|
||||
type="button"
|
||||
@@ -113,9 +169,29 @@ export default function Header({ lang = 'zh', t = {} }) {
|
||||
)}
|
||||
</div>
|
||||
{loggedIn ? (
|
||||
<a class="linklogin" href={SITE.usercenter}>{t.center}</a>
|
||||
<div ref={userRef} class="langwrap usermenu">
|
||||
<button
|
||||
type="button"
|
||||
class="langsel userbtn"
|
||||
onClick={() => setUserOpen((o) => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={userOpen}
|
||||
>
|
||||
<span class="uname">{displayName}</span>
|
||||
<svg class="caret" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{userOpen && (
|
||||
<div class="langmenu" role="menu">
|
||||
<a role="menuitem" href={SITE.usercenter}>{t.mcenter}</a>
|
||||
<button role="menuitem" type="button" onClick={onSwitch}>{t.mswitch}</button>
|
||||
<button role="menuitem" type="button" onClick={onLogout}>{t.mlogout}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
|
||||
<a class="linklogin" href={loginHref}>{t.login}</a>
|
||||
)}
|
||||
<a class="btn btn-primary" href="#download">
|
||||
<Download />
|
||||
|
||||
@@ -23,6 +23,7 @@ const plansData = {
|
||||
feats: [t('pf.free1'), t('pf.free2'), t('pf.free3'), t('pf.free4')],
|
||||
cta: t('price.cta_free'),
|
||||
ctaClass: 'pcta-out',
|
||||
href: '#download',
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
@@ -33,6 +34,7 @@ const plansData = {
|
||||
feats: [t('pf.pro1'), t('pf.pro2'), t('pf.pro3'), t('pf.pro4'), t('pf.pro5')],
|
||||
cta: t('price.cta_pro'),
|
||||
ctaClass: 'pcta-white',
|
||||
href: '#get-code',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
@@ -43,6 +45,7 @@ const plansData = {
|
||||
feats: [t('pf.team1'), t('pf.team2'), t('pf.team3'), t('pf.team4')],
|
||||
cta: t('price.cta_team'),
|
||||
ctaClass: 'pcta-fill',
|
||||
href: '#get-code',
|
||||
highlight: false,
|
||||
},
|
||||
],
|
||||
@@ -75,7 +78,7 @@ function cell(v: string) {
|
||||
|
||||
<div class="wrap">
|
||||
<!-- payment note -->
|
||||
<div class="pay-note">
|
||||
<div class="pay-note" id="get-code">
|
||||
<Icon name="shield-check" />
|
||||
<div>
|
||||
<b>{t('pay.title')}</b><br>
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function PricingPlans({ data }) {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button class={`pcta ${p.ctaClass}`}>{p.cta}</button>
|
||||
<a href={p.href || '#get-code'} class={`pcta ${p.ctaClass}`}>{p.cta}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,12 @@ export const STRINGS: Record<string, Record<Lang, string>> = {
|
||||
'nav.login': { zh: '登录', en: 'Log in', ja: 'ログイン', ko: '로그인', ru: 'Войти', es: 'Iniciar sesión' },
|
||||
'nav.center': { zh: '用户中心', en: 'Account', ja: 'アカウント', ko: '계정', ru: 'Личный кабинет', es: 'Mi cuenta' },
|
||||
'nav.get': { zh: '立即下载', en: 'Get the app', ja: 'アプリを入手', ko: '앱 받기', ru: 'Получить приложение', es: 'Obtener la app' },
|
||||
// 品牌字标:中文显「穿山甲」,其余语言统一显「Pangolin」(英文版 logo 本地化)。
|
||||
'nav.brand': { zh: '穿山甲', en: 'Pangolin', ja: 'Pangolin', ko: 'Pangolin', ru: 'Pangolin', es: 'Pangolin' },
|
||||
// 登录态用户下拉菜单项(3 项 × 6 语)。
|
||||
'menu.center': { zh: '进入用户中心', en: 'Open account center', ja: 'アカウントセンターへ', ko: '계정 센터 열기', ru: 'Личный кабинет', es: 'Ir a mi cuenta' },
|
||||
'menu.switch': { zh: '切换用户', en: 'Switch account', ja: 'アカウントを切替', ko: '계정 전환', ru: 'Сменить аккаунт', es: 'Cambiar de cuenta' },
|
||||
'menu.logout': { zh: '退出登录', en: 'Log out', ja: 'ログアウト', ko: '로그아웃', ru: 'Выйти', es: 'Cerrar sesión' },
|
||||
|
||||
'hero.eyebrow': { zh: '极速 · 稳定 · 省心', en: 'Fast · Stable · Effortless', ja: '高速 · 安定 · 快適', ko: '빠름 · 안정 · 간편', ru: 'Быстро · Стабильно · Без забот', es: 'Rápido · Estable · Sin complicaciones' },
|
||||
'hero.h1': { zh: '极速畅连,\n网络如丝顺滑', en: 'Faster, smoother,\neverywhere', ja: 'もっと速く、もっと滑らかに、\nどこでも', ko: '더 빠르고 더 매끄럽게,\n어디서나', ru: 'Быстрее, плавнее,\nвезде', es: 'Más rápido, más fluido,\nen todas partes' },
|
||||
@@ -140,7 +146,7 @@ export const STRINGS: Record<string, Record<Lang, string>> = {
|
||||
'docs.2t': { zh: '常见问题', en: 'FAQ', ja: 'よくある質問', ko: '자주 묻는 질문', ru: 'Вопросы и ответы', es: 'Preguntas frecuentes' },
|
||||
'docs.2d': { zh: '连接、计费、设备与兑换码的常见疑问。', en: 'Connection, billing, devices and redeem codes.', ja: '接続・請求・デバイス・引き換えコードのよくある疑問。', ko: '연결, 결제, 기기, 등록 코드에 대한 궁금증.', ru: 'Подключение, оплата, устройства и коды активации.', es: 'Conexión, facturación, dispositivos y códigos de canje.' },
|
||||
'docs.3t': { zh: '协议与安全', en: 'Protocol & security', ja: 'プロトコルとセキュリティ', ko: '프로토콜 & 보안', ru: 'Протокол и безопасность', es: 'Protocolo y seguridad' },
|
||||
'docs.3d': { zh: 'WireGuard、加密方式与无日志架构说明。', en: 'WireGuard, encryption and our no-logs architecture.', ja: 'WireGuard、暗号化方式、ノーログ設計の解説。', ko: 'WireGuard, 암호화 방식, 노로그 아키텍처 설명.', ru: 'WireGuard, шифрование и наша архитектура без логов.', es: 'WireGuard, cifrado y nuestra arquitectura sin registros.' },
|
||||
'docs.3d': { zh: 'sing-box + REALITY、加密方式与无日志架构说明。', en: 'sing-box + REALITY, encryption and our no-logs architecture.', ja: 'sing-box + REALITY、暗号化方式、ノーログ設計の解説。', ko: 'sing-box + REALITY, 암호화 방식, 노로그 아키텍처 설명.', ru: 'sing-box + REALITY, шифрование и наша архитектура без логов.', es: 'sing-box + REALITY, cifrado y nuestra arquitectura sin registros.' },
|
||||
'docs.4t': { zh: '隐私政策', en: 'Privacy policy', ja: 'プライバシーポリシー', ko: '개인정보 처리방침', ru: 'Политика конфиденциальности', es: 'Política de privacidad' },
|
||||
'docs.4d': { zh: '我们收集什么、不收集什么,一目了然。', en: 'Exactly what we collect — and what we never do.', ja: '収集するもの・しないものを明確に。', ko: '무엇을 수집하고 무엇을 수집하지 않는지 한눈에.', ru: 'Что мы собираем — и чего не собираем никогда.', es: 'Exactamente qué recopilamos y qué nunca hacemos.' },
|
||||
'docs.read': { zh: '阅读', en: 'Read', ja: '読む', ko: '읽기', ru: 'Читать', es: 'Leer' },
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
/**
|
||||
* Doc.astro — 文档正文页布局(/docs/* 与 /zh/docs/*)。
|
||||
* 复用官网 Header/Footer 与全站样式(tokens.gen / website / site-extra),
|
||||
* 中间是 .doc-page > .doc-article 可读正文容器。构建期单显一种语言。
|
||||
*/
|
||||
import '@fontsource/sora/500.css';
|
||||
import '@fontsource/sora/600.css';
|
||||
import '@fontsource/sora/700.css';
|
||||
import '@fontsource/manrope/400.css';
|
||||
import '@fontsource/manrope/500.css';
|
||||
import '@fontsource/manrope/600.css';
|
||||
import '@fontsource/manrope/700.css';
|
||||
import '@fontsource/noto-sans-sc/400.css';
|
||||
import '@fontsource/noto-sans-sc/500.css';
|
||||
import '@fontsource/noto-sans-sc/700.css';
|
||||
import '@fontsource/jetbrains-mono/400.css';
|
||||
import '@fontsource/jetbrains-mono/500.css';
|
||||
|
||||
import '../styles/tokens.gen.css';
|
||||
import '../styles/website.css';
|
||||
import '../styles/site-extra.css';
|
||||
|
||||
import { createT, type Lang } from '../i18n/strings';
|
||||
import Header from '../components/Header.jsx';
|
||||
import Footer from '../components/Footer.astro';
|
||||
|
||||
interface Props { lang: Lang; title: string; desc?: string }
|
||||
const { lang, title, desc } = Astro.props;
|
||||
const t = createT(lang);
|
||||
|
||||
const HTML_LANG: Record<Lang, string> = { zh: 'zh-CN', en: 'en', ja: 'ja', ko: 'ko', ru: 'ru', es: 'es' };
|
||||
|
||||
const headerT = {
|
||||
product: t('nav.product'),
|
||||
pricing: t('nav.pricing'),
|
||||
download: t('nav.download'),
|
||||
docs: t('nav.docs'),
|
||||
blog: t('nav.blog'),
|
||||
login: t('nav.login'),
|
||||
center: t('nav.center'),
|
||||
brand: t('nav.brand'),
|
||||
mcenter: t('menu.center'),
|
||||
mswitch: t('menu.switch'),
|
||||
mlogout: t('menu.logout'),
|
||||
get: t('nav.get'),
|
||||
suBtn: t('su.btn'),
|
||||
};
|
||||
|
||||
// 导航锚点回主页(文档页无同页锚点):把 #x 改为主页前缀。
|
||||
const home = lang === 'zh' ? '/zh/' : '/';
|
||||
const backHref = `${home}#docs`;
|
||||
const metaTitle = `${title} · ${t('nav.brand')}`;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang={HTML_LANG[lang]}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{/* 首屏渲染前设好 data-theme,避免明/暗切换闪烁(FOUC)。postbuild 会给此内联脚本加 CSP hash。 */}
|
||||
<script is:inline>(function(){try{var t=localStorage.getItem('pg_site_theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t;}catch(e){}})()</script>
|
||||
<title>{metaTitle}</title>
|
||||
{desc && <meta name="description" content={desc} />}
|
||||
<meta name="robots" content="index,follow" />
|
||||
<meta name="theme-color" content="#B96A3D" />
|
||||
</head>
|
||||
<body>
|
||||
<Header client:load lang={lang} t={headerT} />
|
||||
<main class="doc-page">
|
||||
<div class="wrap">
|
||||
<article class="doc-article">
|
||||
<slot />
|
||||
<a class="doc-back" href={backHref}>← {t('nav.docs')}</a>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
<Footer t={t} />
|
||||
</body>
|
||||
</html>
|
||||
@@ -57,6 +57,10 @@ const headerT = {
|
||||
blog: t('nav.blog'),
|
||||
login: t('nav.login'),
|
||||
center: t('nav.center'),
|
||||
brand: t('nav.brand'),
|
||||
mcenter: t('menu.center'),
|
||||
mswitch: t('menu.switch'),
|
||||
mlogout: t('menu.logout'),
|
||||
get: t('nav.get'),
|
||||
suBtn: t('su.btn'),
|
||||
};
|
||||
@@ -66,6 +70,8 @@ const headerT = {
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{/* 首屏渲染前设好 data-theme,避免明/暗切换闪烁(FOUC)。postbuild 会给此内联脚本加 CSP hash。 */}
|
||||
<script is:inline>(function(){try{var t=localStorage.getItem('pg_site_theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t;}catch(e){}})()</script>
|
||||
<title>{t('meta.title')}</title>
|
||||
<meta name="description" content={t('meta.desc')} />
|
||||
<meta name="robots" content="index,follow" />
|
||||
@@ -91,7 +97,7 @@ const headerT = {
|
||||
<WhySignup t={t} />
|
||||
<Pricing t={t} lang={lang} />
|
||||
<Download t={t} />
|
||||
<Docs t={t} />
|
||||
<Docs t={t} lang={lang} />
|
||||
<CtaBand t={t} />
|
||||
<Footer t={t} />
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import Doc from '../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="en" title="FAQ" desc="Common questions about accounts, plans, devices and activation codes.">
|
||||
<div class="doc-eyebrow">Docs</div>
|
||||
<h1>Frequently asked questions</h1>
|
||||
<p class="doc-lede">Short answers to the questions we hear most about accounts, plans, devices and codes.</p>
|
||||
|
||||
<h2>How do I redeem an activation code?</h2>
|
||||
<p>Open the app, go to the account or subscription screen, choose <strong>Redeem code</strong>, paste the code and confirm. Your plan upgrades immediately — no restart needed. Codes are obtained through external channels; there is no checkout on the website or in the app.</p>
|
||||
|
||||
<h2>Which platforms are supported?</h2>
|
||||
<p>Windows, macOS, Android and iOS. One account works across all of them, and your plan and settings follow you between devices.</p>
|
||||
|
||||
<h2>What are the free plan limits?</h2>
|
||||
<p>The free plan gives you 10 minutes of connection time per day on a single basic route, and asks you to watch a short ad before each session. Core encryption and our no-logs promise are included on every plan, free or paid. During the 7-day trial the time limit is lifted.</p>
|
||||
|
||||
<h2>How many devices can I use?</h2>
|
||||
<p>Up to 5 devices on one account at the same time on the Pro plan. Sign in with the same email on each device to keep everything in sync.</p>
|
||||
|
||||
<h2>I forgot my password — what now?</h2>
|
||||
<p>Accounts sign in by email verification code, so there is no fixed password to forget. Just request a fresh code at login and enter it to get back in.</p>
|
||||
|
||||
<h2>Why is there no payment button on the site?</h2>
|
||||
<p>For risk and privacy reasons Pangolin never processes payments in the web or app. You get an activation code through an external channel and redeem it in the client — that keeps the payment flow entirely off our platform.</p>
|
||||
</Doc>
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import Doc from '../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="en" title="Privacy policy" desc="Exactly what we collect, what we never collect, and how it is stored.">
|
||||
<div class="doc-eyebrow">Docs</div>
|
||||
<h1>Privacy policy</h1>
|
||||
<p class="doc-lede">Privacy protection is the baseline of this product, not a marketing line. Here is exactly what we do and don't collect, in plain language.</p>
|
||||
|
||||
<h2>What we collect</h2>
|
||||
<p>We keep only the operational minimum needed to run your account and the service:</p>
|
||||
<ul>
|
||||
<li><strong>Account email</strong> — used to sign in, deliver verification codes and tie your plan to you.</li>
|
||||
<li><strong>Device identifier</strong> — an anonymous ID used to enforce the device limit and sync your plan across devices.</li>
|
||||
<li><strong>Usage statistics</strong> — coarse figures such as connection time and data volume, used for billing limits and capacity planning.</li>
|
||||
</ul>
|
||||
|
||||
<h2>What we never collect</h2>
|
||||
<p>We do not log the content of your traffic, the sites or apps you reach, DNS queries, or any browsing history. There is no per-request connection log tied to what you do online. Because we never gather this data, there is nothing of that kind to disclose or lose.</p>
|
||||
|
||||
<h2>How it is stored</h2>
|
||||
<p>The limited data above is stored on our own infrastructure, encrypted in transit, and retained only as long as it is needed to operate your account and the service. Payments happen entirely through external channels, so no payment card details ever touch our systems.</p>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3>Questions?</h3>
|
||||
<p>Reach out through any of the channels listed in the footer and we'll help clarify how your data is handled.</p>
|
||||
</div>
|
||||
</Doc>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import Doc from '../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="en" title="Protocol & security" desc="Our data plane runs on sing-box with the REALITY transport, plus a strict no-logs architecture.">
|
||||
<div class="doc-eyebrow">Docs</div>
|
||||
<h1>Protocol & security</h1>
|
||||
<p class="doc-lede">How we move your traffic quickly while keeping it private — the transport, the encryption, and the no-logs architecture behind it.</p>
|
||||
|
||||
<h2>Data plane: sing-box + REALITY</h2>
|
||||
<p>Our data plane is built on <strong>sing-box</strong> and uses the <strong>REALITY</strong> transport. REALITY performs a genuine TLS handshake against a real destination, so accelerated traffic blends in with ordinary encrypted web traffic instead of standing out. The result is a connection that stays fast and reliable on demanding networks.</p>
|
||||
|
||||
<h2>End-to-end encryption</h2>
|
||||
<p>Every session is encrypted from your device to the node. Encryption is the baseline for all traffic on every plan — it is not an add-on. Keys are negotiated per session, and the client configuration is rendered and delivered by our control plane rather than assembled on the device.</p>
|
||||
|
||||
<h2>No-logs architecture</h2>
|
||||
<p>We do not record what you browse. Nodes forward traffic without keeping content or connection logs, and the system is designed so there is simply nothing sensitive to hand over. What we do keep is the operational minimum needed to run the service — see the <a href="/docs/privacy/">Privacy policy</a> for the exact list.</p>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3>Kill switch</h3>
|
||||
<p>If the tunnel ever drops, the client blocks traffic instantly so your real IP is never exposed while the connection re-establishes.</p>
|
||||
</div>
|
||||
</Doc>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import Doc from '../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="en" title="Quickstart" desc="Sign up, download and make your first connection in three minutes.">
|
||||
<div class="doc-eyebrow">Docs</div>
|
||||
<h1>Quickstart</h1>
|
||||
<p class="doc-lede">Get from zero to your first fast, stable connection in about three minutes — three steps, no configuration.</p>
|
||||
|
||||
<h2>1. Create an account</h2>
|
||||
<p>Enter your email on the homepage or in the app and confirm the verification code we send you. Registration is free and needs nothing but an email — no payment details, ever. New accounts include a 7-day free trial with full access.</p>
|
||||
|
||||
<h2>2. Download the client</h2>
|
||||
<p>Grab the app for your platform from the <a href="/#download">Download</a> section — Windows, macOS, Android and iOS are supported. One account syncs across every device, up to 5 at once.</p>
|
||||
|
||||
<h2>3. Log in and connect</h2>
|
||||
<p>Open the app, sign in with the same email, and tap the connect button. The app smart-picks the fastest route for you; there is nothing to configure. When you see <strong>Connected</strong>, you're on an accelerated line.</p>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3>Upgrading later</h3>
|
||||
<p>For privacy and risk reasons we never take payment inside the web or app. When you want more, obtain an activation code through an external channel and redeem it in the client to unlock unlimited data and top-speed routes.</p>
|
||||
</div>
|
||||
</Doc>
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import Doc from '../../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="zh" title="常见问题" desc="关于账户、套餐、设备与激活码的常见疑问。">
|
||||
<div class="doc-eyebrow">文档</div>
|
||||
<h1>常见问题</h1>
|
||||
<p class="doc-lede">关于账户、套餐、设备与兑换码,最常被问到的几个问题,简明作答。</p>
|
||||
|
||||
<h2>如何兑换激活码?</h2>
|
||||
<p>打开 App,进入账户或订阅页,选择<strong>兑换激活码</strong>,粘贴激活码并确认,套餐即刻升级,无需重启。激活码通过外部渠道获取;网页与 App 内均不设收银台。</p>
|
||||
|
||||
<h2>支持哪些平台?</h2>
|
||||
<p>Windows、macOS、Android 与 iOS。一个账户全平台通用,套餐与设置在各设备间同步。</p>
|
||||
|
||||
<h2>免费版有哪些限制?</h2>
|
||||
<p>免费版每天可连接 10 分钟,仅含 1 个基础节点,且每次连接前需观看一段短广告。核心加密与无日志承诺在所有套餐(含免费版)中一视同仁。7 天试用期内不受时长限制。</p>
|
||||
|
||||
<h2>可以用几台设备?</h2>
|
||||
<p>专业版一个账户最多 5 台设备同时在线。各设备用同一邮箱登录即可保持同步。</p>
|
||||
|
||||
<h2>忘记密码了怎么办?</h2>
|
||||
<p>账户采用邮箱验证码登录,没有固定密码需要记忆。登录时重新获取一次验证码、输入即可进入。</p>
|
||||
|
||||
<h2>为什么网页上没有支付按钮?</h2>
|
||||
<p>出于风控与隐私考虑,穿山甲不在网页或 App 内直接收款。你通过外部渠道获取激活码、在客户端内兑换 —— 资金流全程不经过我们的平台。</p>
|
||||
</Doc>
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import Doc from '../../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="zh" title="隐私政策" desc="我们收集什么、绝不收集什么,以及如何存储。">
|
||||
<div class="doc-eyebrow">文档</div>
|
||||
<h1>隐私政策</h1>
|
||||
<p class="doc-lede">隐私保护是这款产品的底线,而非营销话术。以下用大白话说清我们收集与绝不收集的内容。</p>
|
||||
|
||||
<h2>我们收集什么</h2>
|
||||
<p>我们只保留运行账户与服务所必需的最小信息:</p>
|
||||
<ul>
|
||||
<li><strong>账户邮箱</strong> —— 用于登录、发送验证码,以及将套餐与你绑定。</li>
|
||||
<li><strong>设备标识</strong> —— 一个匿名 ID,用于限制设备数量、在多设备间同步套餐。</li>
|
||||
<li><strong>用量统计</strong> —— 连接时长、流量等粗粒度数据,用于计费限额与容量规划。</li>
|
||||
</ul>
|
||||
|
||||
<h2>我们绝不收集什么</h2>
|
||||
<p>我们不记录你的流量内容、访问的网站或 App、DNS 查询,也不保留任何浏览历史;不存在与你上网行为绑定的逐条连接日志。因为我们从一开始就不采集这类数据,所以也没有这类数据可供交出或泄露。</p>
|
||||
|
||||
<h2>如何存储</h2>
|
||||
<p>上述有限数据存放在我们自有的基础设施上,传输过程加密,且仅在运行账户与服务所需的期限内保留。收款全部经外部渠道完成,任何银行卡信息都不会触及我们的系统。</p>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3>还有疑问?</h3>
|
||||
<p>通过页脚列出的任一渠道联系我们,我们会进一步说明你的数据是如何被处理的。</p>
|
||||
</div>
|
||||
</Doc>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import Doc from '../../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="zh" title="协议与安全" desc="数据面基于 sing-box + REALITY,配合严格无日志架构。">
|
||||
<div class="doc-eyebrow">文档</div>
|
||||
<h1>协议与安全</h1>
|
||||
<p class="doc-lede">我们如何在保持极速的同时守护隐私 —— 传输方式、加密机制,以及背后的无日志架构。</p>
|
||||
|
||||
<h2>数据面:sing-box + REALITY</h2>
|
||||
<p>我们的数据面基于 <strong>sing-box</strong>,传输采用 <strong>REALITY</strong>。REALITY 会与真实站点完成一次真正的 TLS 握手,使加速流量与普通加密网页流量融为一体、不易被区分,从而在苛刻网络下依然快速稳定。</p>
|
||||
|
||||
<h2>端到端加密</h2>
|
||||
<p>每一次会话都从你的设备到节点全程加密。加密是所有套餐、所有流量的底线,而非附加项。密钥按会话协商,客户端配置由控制面渲染下发,而非在设备本地拼装。</p>
|
||||
|
||||
<h2>无日志架构</h2>
|
||||
<p>我们不记录你浏览了什么。节点只做流量转发,不保留内容或连接日志;整套系统的设计初衷,就是让敏感数据「压根不存在、无从交出」。我们仅保留运行服务所必需的最小运营数据 —— 具体清单见<a href="/zh/docs/privacy/">隐私政策</a>。</p>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3>Kill Switch</h3>
|
||||
<p>一旦隧道中断,客户端立即阻断网络,在连接重建期间杜绝真实 IP 泄露。</p>
|
||||
</div>
|
||||
</Doc>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import Doc from '../../../layouts/Doc.astro';
|
||||
---
|
||||
<Doc lang="zh" title="快速开始" desc="三分钟完成注册、下载与首次连接。">
|
||||
<div class="doc-eyebrow">文档</div>
|
||||
<h1>快速开始</h1>
|
||||
<p class="doc-lede">三步走,约三分钟即可完成第一次极速、稳定的连接,全程无需任何配置。</p>
|
||||
|
||||
<h2>1. 注册账户</h2>
|
||||
<p>在主页或 App 内填写邮箱,输入收到的验证码即可完成注册。注册免费,只要一个邮箱,无需任何付款信息。新账户还附赠 7 天免费试用,功能不设限。</p>
|
||||
|
||||
<h2>2. 下载客户端</h2>
|
||||
<p>在<a href="/zh/#download">下载</a>区选择对应平台的安装包 —— 支持 Windows、macOS、Android 与 iOS。一个账户多端同步,最多 5 台设备同时在线。</p>
|
||||
|
||||
<h2>3. 登录并连接</h2>
|
||||
<p>打开 App,用同一邮箱登录,点一下连接按钮即可。客户端会智能挑选最快线路,无需手动设置。当界面显示<strong>已连接</strong>,你就已经在加速线路上了。</p>
|
||||
|
||||
<div class="doc-card">
|
||||
<h3>之后如何升级</h3>
|
||||
<p>出于风控与隐私考虑,我们不在网页或 App 内直接收款。想要更多时,通过外部渠道获取激活码,在客户端内兑换即可解锁无限流量与极速线路。</p>
|
||||
</div>
|
||||
</Doc>
|
||||
@@ -59,6 +59,125 @@
|
||||
box-shadow: 0 1px 4px rgba(45, 30, 20, 0.08);
|
||||
}
|
||||
|
||||
/* 登录态用户下拉菜单:复用 .langwrap/.langsel/.langmenu 视觉,追加用户名截断
|
||||
与菜单内 <button>(切换用户 / 退出登录需 JS,非纯链接)的等价样式。 */
|
||||
.usermenu .userbtn {
|
||||
max-width: 168px;
|
||||
}
|
||||
.usermenu .uname {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.usermenu .langmenu button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fg1);
|
||||
background: transparent;
|
||||
padding: 8px 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
transition: background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.usermenu .langmenu button:hover {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
/* 文档正文页(/docs/*):承载标题层级 / 段落 / 列表 / 卡片的可读排版。 */
|
||||
.doc-page {
|
||||
padding: 56px 0 88px;
|
||||
}
|
||||
.doc-article {
|
||||
max-width: 760px;
|
||||
}
|
||||
.doc-article .doc-eyebrow {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
.doc-article h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 38px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.doc-article .doc-lede {
|
||||
font-size: 17px;
|
||||
color: var(--fg2);
|
||||
line-height: 1.6;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
.doc-article h2 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 40px 0 0;
|
||||
}
|
||||
.doc-article h3 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
margin: 26px 0 0;
|
||||
}
|
||||
.doc-article p {
|
||||
font-size: 15.5px;
|
||||
color: var(--fg2);
|
||||
line-height: 1.7;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.doc-article ul,
|
||||
.doc-article ol {
|
||||
margin: 12px 0 0;
|
||||
padding-left: 22px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.doc-article li {
|
||||
font-size: 15.5px;
|
||||
line-height: 1.7;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
.doc-article a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.doc-article strong {
|
||||
color: var(--fg1);
|
||||
font-weight: 700;
|
||||
}
|
||||
.doc-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 22px 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin: 26px 0 0;
|
||||
}
|
||||
.doc-card h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.doc-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 44px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 视觉隐藏(无障碍用,当前未强依赖) */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
|
||||
@@ -42,6 +42,11 @@ img,svg{display:block}
|
||||
.linklogin{font-size:14.5px;font-weight:600;color:var(--fg1);cursor:pointer}
|
||||
.linklogin:hover{color:var(--accent)}
|
||||
|
||||
/* 明/暗主题切换(顶栏图标按钮,随 token 自适配) */
|
||||
.themetoggle{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;border:none;background:transparent;color:var(--fg2);cursor:pointer;padding:6px;transition:color var(--dur-fast) var(--ease-out)}
|
||||
.themetoggle:hover{color:var(--accent)}
|
||||
.themetoggle svg{width:17px;height:17px}
|
||||
|
||||
/* ---------- language dropdown ---------- */
|
||||
.langwrap{position:relative;display:inline-block}
|
||||
.langsel{display:inline-flex;align-items:center;gap:6px;border:1.5px solid var(--border-strong);border-radius:var(--radius-full);padding:8px 14px;background:var(--surface);color:var(--fg1);font-family:var(--font-sans);font-size:14px;font-weight:600;cursor:pointer;transition:border-color var(--dur-fast) var(--ease-out),color var(--dur-fast) var(--ease-out)}
|
||||
@@ -166,7 +171,7 @@ section{padding:88px 0}
|
||||
.plan.feat-plan .feats li{color:rgba(255,255,255,.92)}
|
||||
.plan .feats svg{width:16px;height:16px;color:var(--success);flex-shrink:0;margin-top:1px}
|
||||
.plan.feat-plan .feats svg{color:#fff}
|
||||
.plan .pcta{width:100%;border:none;border-radius:var(--radius-full);padding:13px;font-family:var(--font-sans);font-weight:700;font-size:14.5px;cursor:pointer}
|
||||
.plan .pcta{display:block;width:100%;box-sizing:border-box;text-align:center;text-decoration:none;border:none;border-radius:var(--radius-full);padding:13px;font-family:var(--font-sans);font-weight:700;font-size:14.5px;cursor:pointer}
|
||||
.pcta-fill{background:var(--accent);color:#fff}
|
||||
.pcta-white{background:#fff;color:var(--clay-700)}
|
||||
.pcta-out{background:transparent;color:var(--fg2);border:1.5px solid var(--border-strong)!important}
|
||||
|
||||
Reference in New Issue
Block a user