Compare commits

...

21 Commits

Author SHA1 Message Date
wangjia c5949a595a feat(pay): 收款模型改为单地址+唯一金额(#34/34A Phase A-C)
从"每单唯一 HD 地址"改为"单个固定收款地址 + 每单唯一金额",归集成本 O(订单数)→O(1)。
- store: pay_orders 加 user_ref/expect_amount(唯一金额)/matched_tx_id;新 orphan_payments 表;
  ActiveOrderByUser(同用户单订单)、AmountRecentlyUsed(迟到窗口内金额不复用)、TxHandled(幂等)、
  RecordOrphan。去掉每单派生游标。
- pay: CreateOrder(userRef,sku,priceMicro)——同用户单订单校验 + 分配唯一金额(base+随机微尾数[1,9999]、
  cooldown 内不复用),address 恒为收款地址。
- tron: Transfer 加 BlockTs(区块时间秒),取 block_timestamp。
- watcher: 单地址取到账,按"金额==expect && block_ts>建单"匹配 → paid;不匹配的到账 → orphan;幂等。
- httpapi: POST /order 加 user_ref,同用户重复 → 409;main 收款地址=PAY_RECEIVE_ADDRESS 或 xpub index0。
- 测试:唯一金额/同地址、同用户单订单、精确匹配、付错成孤儿、迟到不误配新单、付款早于建单不匹配、
  超时、幂等、409,全绿。README 更新为单地址模型+API(user_ref/精确金额/orphan)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:15:38 +08:00
wangjia 543a54c606 docs(pay): 收款模型定稿为单地址+唯一金额,取代地址池 plan(#34/34A)
单个固定收款地址 + 每单唯一金额(base+微尾数≤0.01U)+ 精确==匹配 + 时间戳防迟到误配 +
孤儿人工对账。归集=1地址(激活一次/扫一笔)最省。前端契约不变(POST /order 返 address+amount),
以后升多地址/GasFree 纯后端切。删除已被取代的地址池 plan。双产物 md+html,登记 index。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:06:59 +08:00
wangjia 5055afdbd7 docs(pay): 地址池收款模型 plan(折中方案,#34/34A)
从每单唯一 HD 地址改为地址池复用(池=峰值并行度、复用摊薄激活/归集)。规则:最小编号
idle 绑单/无 idle 派生/15min 超时释放/同用户单订单。核心安全点:地址复用迟到付款错配
→ 匹配按 tx+金额+时间戳(晚于建单)、每单唯一金额、孤儿付款记录。双产物 md+html,登记 index。
待用户确认后执行(tier-1)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:44:22 +08:00
wangjia fd285708c7 chore(pay): selfcheck.sh 去掉调试打印(派生已核对通过)(#34/34A)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:35:49 +08:00
wangjia e169099524 feat(pay): selfcheck.sh 从 Bitwarden 取 xpub 跑派生自检 + 打印 xpub 便于核对(#34/34A)
rbw 取钱包 A 的 account xpub(公钥,不写死/不落盘)→ go run paywatch selfcheck 打印
前 N 个派生地址,供与 TronLink 插件/Ian Coleman 逐个核对;并打印 xpub 本身 + 长度/前缀,
便于判断存的是 Account(m/44'/195'/0')还是 BIP32(m/44'/195'/0'/0)那个(存错会全错)。
无 $() 命令替换。私钥/助记词永不进此脚本。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:33:24 +08:00
wangjia efa6cffecd feat(pay): paywatch selfcheck + money-critical 验证 runbook(#34/34A)
selfcheck 子命令:用 PAY_ACCOUNT_XPUB 打印前 N 个派生收款地址,供与自己钱包/Ian Coleman
逐个核对(派生错=钱打到无私钥地址)。README 加『收真钱前必过』四步验证:①金标准测试
②Ian Coleman 独立交叉核对 ③真钱包 A selfcheck 比对 ④真链 1 USDT 收→控→测→归闭环。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 08:11:52 +08:00
wangjia 7cf87a764f feat(pay): Phase D 归集 —— 气隙签名 sweep(#34/34A)
动钱代码,按"联网建/广播 + 离线签"气隙流程,离线签名独立验签防篡改:
- wallet: DecodeTronAddress/TronAddressBodyHex(base58check 解码校验)、AddressFromMnemonic(离线校验用)。
- tron/sign: TxID(sha256 raw_data)、SignRawData(secp256k1 → R||S||recid 65B)、RecoverAddressBody。
- tron/abi: ABIEncodeTransferParams(transfer(address,uint256) 参数)、keccak 地址体。
- tron/tx(联网): BuildTransfer(triggersmartcontract 建未签名)、TRC20Balance、Broadcast。
- cmd/sweep: plan/build/sign/broadcast 四段;sign 对每笔独立验:①重算 txid 防篡改 ②收款人+额 ABI
  内嵌 ③USDT 合约内嵌 ④派生地址==owner,任一不符拒签;助记词只经 PAY_SWEEP_MNEMONIC(离线机,不入 arg)。
- 测试:签名可恢复到正确地址、ABI 编码、地址解码 roundtrip 全绿。
- 联网上链部分标注需 Phase E 真链验证;README 补 sweep 用法 + 验签说明 + gas 提醒。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 04:12:04 +08:00
wangjia 4bb92209ca feat(pay): 收款引擎 —— 建单/派生地址 + TronGrid watcher 侦测到账(#34/34A Phase B.3+C)
- store(SQLite,modernc 纯 Go):pay_orders + addr_cursor(HD 派生游标,地址不复用);
  建单/查单/ListPending/MarkPaid(幂等,仅 pending→paid)/MarkExpired。
- pay 服务:CreateOrder 每单 NextAddrIndex→从 xpub watch-only 派生唯一收款地址→写 pending 单(TTL 15min)。
- tron:TronGrid 客户端读已确认 TRC20 到账(only_confirmed + USDT 合约,micro-USDT 整数)。
- watcher:Tick 先过期逾期单,再对每个 pending 单查到账、金额≥期望→MarkPaid;幂等(同 tx 只认一次)、
  网络错误跳过下轮重试;Loop 定时轮询。
- httpapi:POST /order、GET /order/{orderNo}、/healthz;cmd/paywatch 用 env 装配 + 优雅退出。
- 测试:store/service/watcher(mock TronGrid)/httpapi 全绿——建单派生地址正确、到账侦测、
  欠额不认、幂等、超时过期、404/400。热服务无私钥。
- README:安全模型 + Phase A 离线备钱包步骤 + 运行/API/测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:57:47 +08:00
wangjia 4393edf1d7 feat(pay): pangolin-pay 钱包派生模块(#34/34A · 加密货币交易引擎 Phase B.2)
新建独立 Go module pay/(pangolin-pay:独立 VPS 跑,与控制面分开、隔离 crypto 依赖)。
wallet 包:
- AddressFromAccountXpub(xpub, change, index):watch-only 从账户 xpub 派生 TRON 地址
  (Keccak-256 legacy → 后20字节 → 0x41 → base58check),watcher 用,不碰私钥。
- seed.go(离线专用):助记词→account xpub / 地址私钥(hex),给 Phase A 导 xpub、Phase D 归集签名。
- 测试:①派生一致性——xpub 路径与私钥路径逐个相等(证明每个收款地址对得上签名私钥);
  ②金标准向量锁定实现防回归。⚠️ 金标准需按 A.4 用 Ian Coleman 交叉核对一次。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:50:24 +08:00
wangjia 9a8fec2e9c fix(usercenter): 加 no-FOUC 内联脚本,消除刷新时明暗主题整页配色闪烁
layout 之前把 data-theme 硬编码 light、挂载后才由 UIProvider effect 应用保存的主题,
暗色偏好用户每次刷新先见浅色再翻暗色 = 整页配色重绘(叠加内容加载,观感"刷两次")。

照官网 Site.astro 做法:<head> 内联脚本在首屏渲染前读 localStorage(pg_uc_theme/
pg_uc_lang)设好 data-theme 与 html lang。/user/* CSP 允许 unsafe-inline 脚本,直接内联;
脚本为编译期常量、无插值、只读 localStorage,无 XSS 风险。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:04:23 +08:00
wangjia 101b31e073 fix(usercenter): 消除刷新"重绘两次" —— 引导期直接渲染外壳骨架,去掉多余首帧
上一版为防水合不一致加了 !mounted 中性首帧,反而制造了额外一段:一次 Cmd+R 走
裸 spinner(mounted=false)→ 应用外壳(乐观)→ 内容区(overview),三段重绘 =
用户看到的"刷新两次(先主显示区、后整页)"。

改:去掉 mounted gate;引导期(!ready)一律渲染 appShell(spinner)——外壳骨架
(顶栏/导航就位、内容区加载态)。因 render 期不读 localStorage/hasRefresh,SSR 与
客户端首帧一致、无水合不一致,静态 HTML 首帧即外壳。引导完成后只把内容区从 spinner
换成真实视图,顶栏/导航原地不动 → 刷新只有内容区一次替换,不再整页重绘。

代价:未登录用户刷新 /user/ 会先见外壳骨架再落登录页(极短、且随即要登录),
可接受;已登录用户(刷新的绝大多数场景)全程外壳稳定、无闪。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:01:29 +08:00
wangjia 11ad888ee7 fix(usercenter): 刷新页面即被登出 —— refresh 改用请求体传 token(修致命鉴权 bug)
根因:服务端 POST /v1/auth/refresh 从**请求体** {refresh_token} 读取(handler.go
Refresh + 原生 Flutter auth_api.dart 均如此),但 web 客户端 refresh() 误把 refresh
token 放进 X-Refresh-Token **请求头**(只有 /auth/logout 读该头)。服务端拿到空 body
→ 400 auth.invalid_request → 客户端 catch → clearSession → **每次刷新页面即被登出**。

线上实测证实:POST 带头无体 → invalid_request(空);带体 {refresh_token} → invalid_token
(正常解析)。故服务端读体、web 发头 = 必然掉线。

改:web refresh() 改用 body:{ refresh_token } 传参(与原生客户端、服务端一致);
无需改/重部署服务端。logout 仍走 X-Refresh-Token 头(服务端 logout 读头,匹配)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:52:21 +08:00
wangjia 53d8ded7f6 fix(usercenter): 刷新 /user/ 不再整页重绘(引导期乐观渲染外壳)
上一版把首帧从登录页改成居中 Loading,消除了闪登录页,但用户仍感刷两次:
静态导出首帧无法知登录态 → 先渲染裸 Loading(无顶栏/导航)→ 再出现完整外壳
→ 再拉 /me 填内容。顶栏在裸 Loading 到外壳间消失再出现,观感=整页重绘两次。

改:抽出可复用 appShell(content);mount 后引导仍进行(!ready)时,若本地有 refresh
token,乐观渲染完整外壳(顶栏/导航就位,仅内容区转圈),没 token 才直接登录页。
于是顶栏/导航从乐观态到已登录态始终在位,只有内容区替换 → 不再整页重绘。

SSR 安全:!mounted(SSR + 首次客户端渲染)仍渲染中性 spinner(不读 localStorage),
与服务端 HTML 一致防水合不一致;mount 后才据 hasRefresh() 决定外壳/登录页。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:43:24 +08:00
wangjia e3e1d43d5d fix(usercenter): 刷新 /user/ 不再先闪登录页(引导期一律加载态)
根因:静态导出 SSR 首屏读不到 localStorage(hasRefresh 服务端恒 false),旧守卫
!ready&&hasRefresh() 在此时为 false → 首屏渲染成登录页 → 刷新 /user/ 浏览器先显示
登录页 HTML,再水合+续期跳回面板(闪现);且 render 期调 hasRefresh 造成 SSR/客户端
不一致加剧闪现。

修:引导未完成(!ready)一律显示加载态(不读 localStorage、不渲染登录页),SSR 与
客户端首屏一致;引导完再定:已登录→面板、未登录/失败→登录页。刷新不再闪登录页。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:28:17 +08:00
wangjia 09b1944376 fix(usercenter): 续期失败时清本地会话,修主页↔登录页来回刷
根因:引导时 api.refresh() 失败(refresh token 过期/被撤销/Redis 丢 JTI 白名单)
只 setAuthed(false),没清 localStorage 的 pg_uc_refresh/pg_uc_email → 官网仍据此
显示'用户中心' → 点进 /user/ 又续期失败回登录页 → 来回刷。

修:catch 里加 clearSession() 清掉残留 token,官网随即回到'Log in',状态一致、
不再来回刷。(登录态正常保持 30 天=refresh token TTL;失败多因会话真失效。)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:23:35 +08:00
wangjia e0912dd819 fix(website): 定价卡片 3 个按钮补链接(原纯 button 无响应)
PricingPlans.jsx 的 pcta 是纯 <button> 无 onClick/href → 点击无反应。改为
<a href>:Free「Download free」→ #download 下载区;Pro/Team「Get a code」→
#get-code(给渠道区 .pay-note 加 id,滚到购码渠道:自助店/USDT/Telegram/LINE/
邮箱)。.pcta 补 display:block/text-align:center/text-decoration:none 让 <a>
渲染同按钮。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:09:29 +08:00
wangjia f4c5f93370 fix(website): 主题切换按钮缩小,与用户中心一致(透明底/无框/17px 图标)
原 38×38 圆框 + surface 底太大;改为透明底、无边框、padding 6、图标 17px,
和用户中心顶栏主题按钮同款尺寸。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:13:01 +08:00
wangjia 6dcf582225 feat(website): 官网加明/暗主题切换(默认浅色,无 FOUC)
- Header 加 Moon/Sun 切换按钮(.themetoggle class,走语义 token,无内联 style),
  切 document.documentElement.dataset.theme + 存 localStorage pg_site_theme,
  挂载读回同步;移动端也能切。
- Site.astro + Doc.astro <head> 加 <script is:inline> 无 FOUC 初始化(首屏前读
  localStorage 设 data-theme;postbuild csp-hashes 已加 script hash 放行)。
- 暗色机制:tokens.gen.css 已含 [data-theme=dark] 块,官网 127 处语义 token 自动
  适配;23 处 light 专属原色(公告条/CTA/主推套餐卡 clay 品牌面 + 恒暗页脚)均
  有意固定,保留。
- 审计:所有元素走唯一真相源(check-ds/check-l1-sync/check_ds_code 三闸全绿)。

验证:check-l1-sync ✓ · build ✓(CSP hash 覆盖 FOUC 脚本)· redline 0 · Header
零内联 style。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:58:35 +08:00
wangjia 9ed7588ec0 fix(usercenter): 删冗余 Preferences 卡片 + 修登录态进用户中心闪登录页
1. Settings 页删掉 Preferences 卡片(语言/主题切换)——顶栏已有语言下拉 + 主题
   切换按钮,重复。连带删只被它用的 Preferences/Row/Seg 函数 + useUI 导入。
2. 修 bug:从官网登录态点"用户中心"进 /user/ 会先闪登录页再跳面板。根因:渲染
   守卫在 !ready(会话续期中)时也直接渲染 Login。改为:本地有 refresh token 且
   续期未完成时显示加载态(非登录页),续期成功→面板、失败/无 token→登录页。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:22:11 +08:00
wangjia 87647cae34 fix(usercenter): Subscription/Redeem/Referral/Settings 布局宽度对齐 Overview
这 4 页根布局各自设了更窄的 maxWidth(720/680),而 Overview 无 maxWidth 填满
1000px 居中容器 → 这几页内容贴左、右侧留白、与 Overview 不一致。去掉各自的
maxWidth,统一填满主容器(maxWidth:1000 margin:0 auto),宽度与 Overview 一致、
居中对齐。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:13:31 +08:00
wangjia 620d8148f3 fix(usercenter/overview): 4 处格式调整
1. 顶栏 Sign out 按钮加 whiteSpace:nowrap,不再折成两行。
2. 近 7 日柱状图数字保留 2 位有效数字(Number(v.toPrecision(2))),不再显示
   9.285392755642533 这种长数。
3. 英文星期标签 M/T/W/T/F/S/S → Mon/Tue/Wed/Thu/Fri/Sat/Sun(消歧义;中文
   一~日 本就清晰不改)。
4. 图表卡与 Quick actions 卡 alignItems start→stretch,两卡等高、上下对齐。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:09:51 +08:00
41 changed files with 2724 additions and 75 deletions
+30
View File
@@ -44,6 +44,11 @@
</div>
<h2>设计方案 / Specs</h2>
<a class="doc" href="payment-reseller-fulfillment-design.html">
<div class="t">支付落地方案 · 发卡/Reseller 收款 + 激活码自动发货 <span class="tag html">HTML</span></div>
<div class="d">把「收钱」与品牌 VPN 主体解耦:收款外包给发卡平台/Reseller(他们承担支付宝/微信跑分、冻卡、跑路风险),你只交付「激活码」;客户端/用户中心只认码,codes 模块核销即生效。含完整流程图(钱流/码流/结算)、两种对接模型(A 预充卡密库存 / B API 实时签发 POST /v1/codes/issue)、三阶段落地节奏、对账与风险边界(主体永不碰跑分/中国支付)。灰产“付完秒发货”体验的合规化替身。</div>
<div class="path">docs/payment-reseller-fulfillment-design.html</div>
</a>
<a class="doc" href="cicd-design.html">
<div class="t">CI/CD 全流程(tag 触发编译/发版/部署)<span class="tag html">HTML</span></div>
<div class="d">#30。参考 jiu 的 scripts/ci + .gitea/workflowstag 触发(site-v*/server-v*/client-v*)→ 编译 → 测试 → Gitea release → 部署。runner 混合(nas=官网+服务端容器化 / mac=Android+macOS / windows=Windows)。服务端部署固化 F3/F4「备份→migrate→换二进制→重启→健康检查+回滚」;官网部署 pangolin.yanmeiai.com;客户端 apk/dmg/exe 挂 release 喂官网下载链接。密钥作用域:Apple/token 账户级、部署 key/Android keystore 仓库级。范围 A~F(排除 iOS/#26/#25)。</div>
@@ -86,6 +91,26 @@
</a>
<h2>实现计划 / Plans</h2>
<a class="doc" href="pay-single-address-plan.html">
<div class="t">pangolin-pay 单地址 + 唯一金额收款模型(定稿,#34/34A)<span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-09-pay-single-address.md</code>(含 checkbox)。<b>定稿:单个固定收款地址 + 每单唯一金额</b>(取代每单唯一 HD 地址/地址池)。归集=1 地址(激活一次、扫一笔)最省;订单靠金额区分。唯一金额=base+微尾数(≤0.01 USDT);精确 == 匹配 + 时间戳(晚于建单)防迟到误配;付错→孤儿人工对账。前端契约不变(POST /order 返回 address+expect_amount)→ 以后升多地址/GasFree 纯后端切、前端零改。5 Phase:数据模型→建单→watcher 匹配/超时/孤儿→归集(能量租赁)→并发/付错真链验证。</div>
<div class="path">docs/pay-single-address-plan.html · 真相源 docs/superpowers/plans/2026-07-09-pay-single-address.md</div>
</a>
<a class="doc" href="crypto-tx-engine-plan.html">
<div class="t">pangolin-pay 加密货币交易引擎(#34 第一块,可独立验证)<span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-09-crypto-tx-engine.md</code>(含 checkbox)。#34 里"加密货币交易"核心的独立可执行件:钱包(两套助记词 A 运营/B 金库,离线生成、热服务只持 xpub)→ Go 服务 pangolin-pay HD 派生收款地址(对齐 Ian Coleman)→ TronGrid watcher 侦测 TRC20 到账(每单唯一地址+金额、确认、幂等)→ 归集 sweep(离线签名、TRON gas 两步/能量租赁)→ 真实 1 USDT 端到端验证。不含独角数卡/发码,跑通后再接门面。</div>
<div class="path">docs/crypto-tx-engine-plan.html · 真相源 docs/superpowers/plans/2026-07-09-crypto-tx-engine.md</div>
</a>
<a class="doc" href="payment-clean-usdt-plan.html">
<div class="t">收款闭环 · 独角数卡 + 自托管 USDT(TRC20) + webhook JIT#34<span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-09-payment-clean-usdt-loop.md</code>(含 checkbox)。最干净长期方案:门面独角数卡(独立海外 VPS)+ 自托管 TRC20 HD 钱包(热服务只持 xpub、watch-only,绝不持私钥)+ 自建 TronGrid watcher(每单唯一地址侦测到账)+ webhook JIT 发码(新增控制面 <code>POST /internal/codes/mint</code>,HMAC,售出才产合法码)。复用已就绪 codesredeem/批次/webhook)。6 Phase:钱包 → watcher → mint 端点 → 独角数卡 epay+API提货对接 → 部署/归集/变现/对账 → 端到端验证。全程无跑分、收款终点自托管、变现出金 US LLC。</div>
<div class="path">docs/payment-clean-usdt-plan.html · 真相源 docs/superpowers/plans/2026-07-09-payment-clean-usdt-loop.md</div>
</a>
<a class="doc" href="payment-a-selfhosted-store-plan.html">
<div class="t">方案A · 自建发卡网(独角数卡)落地细化 <span class="tag html">HTML</span></div>
<div class="d">门面自建(独角数卡,独立 VPS 隔离部署)+ USDT 收款 + 激活码自动发货。<b>关键:后端已就绪</b>——发卡店回调 <code>POST /webhook/store/codes</code>HMAC+时间戳+nonce 防重放)、兑换 <code>POST /v1/redeem</code>(JWT)、批次生成/导出/作废均在 server/internal/codes。含拓扑图、两种发货模型(A 预充卡密零胶水 / B webhook JIT 更安全,待补 /internal/codes/mint 单源产码)、SKU 映射、USDT 两条接入路子、落地步骤与风险边界。</div>
<div class="path">docs/payment-a-selfhosted-store-plan.html</div>
</a>
<a class="doc" href="frontend-ds-refactor-plan.html">
<div class="t">前端设计系统治理重构(ds-flow 全端)<span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code>(含 checkbox)。用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源·代码镜像·静态闸拦漂移·golden/fidelity 双级像素验收兜底」。<b>非从零 bootstrap(已约 65% 达标)</b>:补原型三件套(atoms.css/icons.js/index.html 登记页)+ Web 共享原子层去重(各自实现+同源闸)+ 硬编码色/fidelity 闸 + 启用 pre-commit。6 阶段:CLAUDE.md → 原型单源 → Web token 同源 → Web 原子对齐 → Flutter golden 补齐 → 闸挂满。主题保持 light/dark。</div>
@@ -128,6 +153,11 @@
</a>
<h2>知识库 / 调研</h2>
<a class="doc" href="payment-channels-overview.html">
<div class="t">支付渠道选型总览 <span class="tag html">HTML</span></div>
<div class="d">各支付渠道候选与甄别标准(研究起点,非背书)。核心心智:「发卡平台」= 门面 × 通道两层分离,灰/干净分水岭在通道不在门面。候选 A 自建发卡网(独角数卡/acg-faka/KamiFaka)· B 加密网关(NOWPayments/Cryptomus/CoinGate)· C 自建 USDT 监听(TronGrid)· D 官方鹅(TG Stars/IAP)· E 支付宝微信第三方(跑分层,不列名单)。含决策矩阵 + 尽调清单 + 推荐起步组合(独角数卡+USDT)。</div>
<div class="path">docs/payment-channels-overview.html</div>
</a>
<a class="doc" href="frontend-overview.html">
<div class="t">前端全景(ds-flow 设计系统治理)<span class="tag html">HTML</span></div>
<div class="d">Flutter 五端 + 官网 + 用户中心的设计系统治理全景:一次 UI 改动标准路径、目录地图、三层真相源模型、令牌 codegen、四道静态闸「违规谁拦」、像素验收(golden 双主题 + fidelity 待建)、响应式五端、规则速查。原型单源 design/prototype/tokens/atoms/icons/index.html)、check-ds/check-l1-sync/check_ds_code/codegen-drift 四闸进 CI、golden 全量 34 绿含 CJK。</div>
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pangolin-pay 单地址 + 唯一金额收款模型 实现计划</title>
<style>
:root{--bg:#0f1117;--panel:#171a22;--panel2:#1d2129;--fg:#e6e8ee;--fg2:#a8afbd;--accent:#e0884f;--accent2:#5fb0c9;--ok:#5ec27a;--bad:#e06a6a;--warn:#e0b84f;--border:#272c36;--mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:28px;line-height:1.3;margin:0 0 8px}
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
h2{font-size:20px;margin:40px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border);color:var(--accent)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.85em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px}
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
ul.ck{list-style:none;padding-left:4px}
ul.ck li{position:relative;padding-left:26px;margin:7px 0}
ul.ck li::before{content:"☐";position:absolute;left:0;color:var(--fg2)}
ul{padding-left:22px} li{margin:6px 0}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.small{color:var(--fg2);font-size:13px}
a{color:var(--accent2)} .back{display:inline-block;margin-bottom:24px;font-size:13px} b{color:#fff}
.okbox{background:rgba(94,194,122,.07);border:1px solid rgba(94,194,122,.32);border-radius:12px;padding:14px 18px;margin:14px 0}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 文档索引</a>
<h1>pangolin-pay 单地址 + 唯一金额收款模型</h1>
<p class="sub">2026-07-09 · <span class="tag info">阅读版</span> · 执行真相源 <code>docs/superpowers/plans/2026-07-09-pay-single-address.md</code> · #34/34A 收款模型定稿</p>
<div class="lead">
<b>定稿:单个固定收款地址 + 每单唯一金额。</b> 归集 = 1 个地址(激活一次、扫一笔),成本最省;订单靠<b>金额</b>区分,不靠地址。前端契约不变(<code>POST /order</code> 返回 <code>address+expect_amount</code>)→ 以后升多地址/GasFree 是<b>纯后端换实现</b>,前端零改动。
</div>
<div class="okbox">
<b>唯一金额:</b><code>expect_amount = base + tail</code>,base=价格×1e6(micro-USDT),<b>tail∈[1,9999] micro</b>(偏差&lt;0.01 USDT,价格几乎不变),迟到窗口内不复用。<b>匹配=精确 ==</b>:找"到收款地址、value==expect_amount、block_ts&gt;建单"的转入 → paid。支付页显示<b>可复制的精确金额</b>。付错/迟到抹尾数 → 孤儿人工对账。
</div>
<h2>Phase A — 数据模型</h2>
<ul class="ck">
<li><b>A.1</b> pay_orders 加 user_ref / expect_amount(唯一金额)/ matched_tx_id;address 恒为收款地址。</li>
<li><b>A.2</b> tail 分配支撑:活跃订单已用 tail + 近期冷却(迟到窗口不复用)。</li>
<li><b>A.3</b> orphan_payments(tx_id唯一/value/block_ts/handled)。</li>
<li><b>A.4</b> 配置 PAY_RECEIVE_ADDRESS(或从 xpub 派生 index 0)。</li>
</ul>
<h2>Phase B — 建单</h2>
<ul class="ck">
<li><b>B.1</b> CreateOrder(user, sku, price):同用户无活跃单 → 分配唯一金额 → 写 pending(TTL 15min)。</li>
<li><b>B.2</b> tail 分配(活跃间不撞 + 迟到窗口不复用);GetOrder。</li>
</ul>
<h2>Phase C — watcher</h2>
<ul class="ck">
<li><b>C.1</b> 匹配:pending 单查收款地址转入,value==expect_amount 且 block_ts&gt;created → paid + tx。</li>
<li><b>C.2</b> 超时 → expired。</li>
<li><b>C.3</b> 孤儿:到账不匹配任何活跃订单 → orphan_payments(去重)。</li>
<li><b>C.4</b> 幂等 + 崩溃恢复;TronGrid 取 block_timestamp。</li>
</ul>
<h2>Phase D — 归集</h2>
<ul class="ck"><li><b>D.1</b> 扫收款地址余额 → 冷钱包(能量租赁);一地址一笔。</li></ul>
<h2>Phase E — 验证</h2>
<ul class="ck">
<li><b>E.1</b> 单测:唯一金额不撞、精确匹配、付错/迟到成孤儿、超时、同用户单订单、幂等、时间戳过滤。</li>
<li><b>E.2</b> 真链:两并发订单不同金额同地址各 paid;付错成孤儿;归集。</li>
</ul>
<h2>不在本轮</h2>
<ul><li>多地址/地址池、GasFree(#35)、能量租赁自动化、发货侧 —— 以后需要纯后端切,前端不动。</li></ul>
<p class="small" style="margin-top:32px">相关:<a href="crypto-tx-engine-plan.html">加密货币交易引擎(已实现钱包/tron/sweep)</a> · <a href="payment-channels-overview.html">渠道选型</a></p>
</div>
</body>
</html>
@@ -0,0 +1,69 @@
# pangolin-pay 单地址 + 唯一金额收款模型(定稿)
> #34/34A 收款模型**定稿**。取代"每单唯一 HD 地址"(激活/归集随订单数线性涨)与"地址池"(仍多地址)。
> 最终选:**单个固定收款地址 + 每单唯一金额**。归集 = **1 个地址**(激活一次、扫一笔),成本最省;
> 订单靠**金额**区分,不靠地址。
>
> **前端契约不变**:`POST /order` 仍返回 `{address, expect_amount}`,client 只用每单返回值 → 以后要升
> 多地址/GasFree 是**纯后端换实现**,前端零改动(见下"契约约定")。
>
> 已实现的 wallet 派生 / tron(TronGrid 读到账、建交易、签名)/ cmd/sweep 复用;主要改 **store / pay(建单)/
> watcher(匹配)**。现有代码是"每单派生新址",本轮改为"单地址 + 唯一金额"。
## 决策(已定)
- **收款地址**:钱包 A 的地址 0(`m/44'/195'/0'/0/0`),从配置注入或由 xpub 派生。所有订单收到**这一个地址**。
- **唯一金额** ⭐:`expect_amount = base + tail`。base = 价格 ×1e6(micro-USDT);**tail 取方案 A(微尾数)**:
`tail ∈ [1, 9999]` micro(偏差 ≤ 0.009999 USDT,**价格几乎不变**)。tail 在**迟到窗口内不复用**。
- **匹配 = 精确 `==`**:watcher 找"到收款地址、`value == expect_amount``block_ts > order.created`"的 TRC20 转入 → paid。
支付页显示**可一键复制的精确金额** + "请付精确金额"提示。
- **15min 超时** → expired → 提示重建订单(该 tail 一段时间内不复用)。
- **同用户单订单**:同时只能一个活跃(pending)订单。
- **孤儿付款**:到该地址但 `value` 不匹配任何活跃订单(付错/迟到抹了尾数)→ 记 `orphan_payments`,人工对账/补发。
- **归集**:定期扫**这一个地址**余额 → 冷钱包(**能量租赁** ~$0.1–1/笔)。激活一次、归集一笔。
## 契约约定(钉死,保证以后单↔多地址纯后端)
> **client(下单页/独角数卡)只使用每单 `POST /order` 返回的 `address` + `expect_amount`,绝不硬编码/缓存地址。**
> 只要守住这条,单地址 ↔ 多地址 ↔ GasFree 都是后端内部换实现,前端与 VPN app 都不改。
## Phase A — 数据模型
- [ ] `pay_orders` 改:加 `user_ref``expect_amount`(唯一金额,micro-USDT)、`matched_tx_id`;`address` 恒为收款地址。
- [ ] tail 分配支撑:记录**当前活跃订单已用 tail**(避免撞)+ **近期已用 tail 冷却**(迟到窗口内不复用);或全局计数器 + 去重校验。
- [ ] `orphan_payments` 表:`tx_id(唯一) / value / block_ts / created_at / handled(bool)`
- [ ] 配置:`PAY_RECEIVE_ADDRESS`(或从 `PAY_ACCOUNT_XPUB` 派生 index 0,与钱包 A 地址 0 一致)。
## Phase B — 建单(pay 服务)
- [ ] `CreateOrder(userRef, sku, priceMicro)`:① 校验**同用户无活跃单**(有则返回现有/拒);② 分配**唯一金额**(base + 未占用 tail);③ 写 `pending` 单(TTL 15min),`address` = 收款地址。
- [ ] **tail 分配**:与当前所有活跃订单不撞 + 迟到窗口内不复用(记录+回收)。
- [ ] `GetOrder`
## Phase C — watcher 改造(匹配 / 超时 / 孤儿)
- [ ] **匹配**:对每个 `pending` 单,查**收款地址**的 TRC20 转入,筛 `value == expect_amount` **且 `block_ts > order.created`**`paid` + 记 `matched_tx_id`
- [ ] **超时**:`pending` 过期 → `expired`
- [ ] **孤儿**:收款地址的到账 tx 匹配不到任何活跃订单 → 记 `orphan_payments`(按 tx_id 去重)。
- [ ] **幂等**(tx_id 全局去重,含 orphan)+ **崩溃恢复**;TronGrid 需取 `block_timestamp` 供时间过滤。
## Phase D — 归集(复用 cmd/sweep)
- [ ] 扫**收款地址**余额 → 冷钱包(**能量租赁**,先手动 runbook);一地址一笔,与订单状态解耦。
## Phase E — 测试 + 真链验证
- [ ] 单测:唯一金额分配不撞、精确匹配、**付错/迟到金额成孤儿**、超时、同用户单订单、幂等、**时间戳过滤**(旧到账不误配新单)、崩溃恢复。
- [ ] 真链:两并发订单**不同唯一金额、同一地址**各自付款到 paid;故意**付错金额** → 成孤儿不误配;归集该地址到冷钱包。
## Verification / 判据
- `go test ./...`(store/pay/watcher 新逻辑全绿,重点覆盖唯一金额/精确匹配/孤儿/时间戳)。
- 真链:并发不同金额 + 付错成孤儿 两场景正确;归集一笔搞定。
- 成本:激活一次、归集 O(1);小额高频不再被激活/归集费拖累。
## 不在本轮 / 以后(纯后端可切)
- 多地址 / 地址池(容错更好但更贵,以后纯后端切,前端不动)。
- GasFree(#35,免 TRX 但每笔固定 1 USDT,适合少地址批量)。
- 能量租赁自动化;发货侧(独角数卡 + /internal/codes/mint)另排。
+134
View File
@@ -0,0 +1,134 @@
# pangolin-pay
自托管 USDT(TRC20) 收款服务。**单个固定收款地址 + 每单唯一金额**:所有订单收到同一个地址,
靠**唯一金额**(基准价 + 微尾数,≤0.01 USDT)区分。watcher 轮询 TronGrid,按
**"到账 tx + 精确金额 + 区块时间晚于建单"** 匹配订单;匹配不到活跃订单的到账(付错/迟到)→
`orphan_payments` 人工对账。归集(把钱扫到冷钱包)是**独立离线步骤**,本服务**不持私钥**。
> 收款地址 = 钱包 A 的地址 0(`m/44'/195'/0'/0/0`),配置注入或由 xpub 派生。
> 前端契约(`POST /order` 返回 address+expect_amount)与地址模型解耦——以后要升多地址/GasFree
> 是纯后端换实现,前端零改动。模型定稿见 `docs/pay-single-address-plan.html`。
> 属 #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 交给本服务。**
## Money-critical 验证(收任何真钱之前必须全过)
派生错 = 把买家的钱打到你没私钥的地址,钱直接丢。上真钱前逐步核死:
**① 代码自检(金标准向量)**
```bash
cd pay && go test ./internal/wallet -run TestKnownVector -v # 必须 PASS
```
**② 独立交叉核对(证明代码 vs 独立实现一致)** —— 断网开 Ian Coleman `bip39-standalone.html`,
输入测试助记词 `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`,
Coin=TRX、BIP44,核对 Derived Addresses 前 3 行必须等于:
```
m/44'/195'/0'/0/0 TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH
m/44'/195'/0'/0/1 TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK
m/44'/195'/0'/0/2 TYJPRrdB5APNeRs4R7fYZSwW3TcrTKw2gx
```
且 Account Extended Public Key = `xpub6D1AabNHCupeiLM65ZR9UStMhJ1vCpyV4XbZdyhMZBiJXALQtmn9p42VTQckoHVn8WNqS7dqnJokZHAHcHGoaQgmv8D45oNUKx6DZMNZBCd`
**③ 你的真钱包 A(护你自己钱的那道闸,最重要)** —— 用你钱包 A 的真 account xpub:
```bash
PAY_ACCOUNT_XPUB="<你钱包A的account xpub>" go run ./cmd/paywatch selfcheck 5
```
把输出的地址,和你钱包 A(在 Ian Coleman 里同一 xpub、或 TronLink/imToken 里钱包 A)显示的
地址 0..4 **逐个比对**。**全部一致才可上线收款;有一个不一致,立即停,别收任何钱。**
**④ 真链闭环(Phase E,最终证明)** —— 小额:
1. `POST /order` 拿地址 → 往它转 **1 USDT(TRC20)**
2. 用你钱包 A 打开 → **确认这 1 USDT 出现在你钱包 A 名下**(证明你确实控制这个地址)。
3. `GET /order/{id}` 应变 `paid`(证明 watcher 侦测正确)。
4.`cmd/sweep` 把这 1 USDT 归集到冷钱包 B → **确认 B 收到**(证明签名/广播正确)。
全过 = 收→控→测→归 四环闭合,可放量。
## 运行
```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 {"user_ref":"buyer123","sku":"pro-year","amount":5000000} # amount = 基准价 micro-USDT(1e-6)
→ 201 {"order_no","address","expect_amount","status":"pending","expires_at"}
# expect_amount = 基准 + 唯一微尾数;支付页要显示"请付精确金额 expect_amount"
→ 409 {"error":"user already has an active order"} # 同一 user_ref 同时只能一个活跃订单
GET /order/{orderNo} → 200 {..., "status":"pending|paid|expired","tx_id"}
GET /healthz → 200 ok
```
门面(独角数卡)下单时调 `POST /order`(带 `user_ref`)拿 **address + expect_amount**;
支付页显示"往 address 付**精确的** expect_amount"(可复制),轮询 `GET /order/{id}` 直到 `paid`
用户付错金额 → 该到账进 orphan,需人工对账。
配置:`PAY_RECEIVE_ADDRESS`(单收款地址)或 `PAY_ACCOUNT_XPUB`(自动派生 index 0)。
## 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。
+131
View File
@@ -0,0 +1,131 @@
// 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"
"fmt"
"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/wallet"
"github.com/wangjia/pangolin/pay/internal/watcher"
)
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// selfcheck prints the first N receiving addresses derived from PAY_ACCOUNT_XPUB.
// MONEY-CRITICAL: compare these against your own wallet app / iancoleman.io for
// the SAME account xpub. If they don't match, the service would hand buyers
// addresses you cannot spend — do NOT accept any payment until they match.
func selfcheck() {
xpub := os.Getenv("PAY_ACCOUNT_XPUB")
if xpub == "" {
fmt.Fprintln(os.Stderr, "PAY_ACCOUNT_XPUB is required")
os.Exit(1)
}
n := 5
if len(os.Args) > 2 {
if v, err := strconv.Atoi(os.Args[2]); err == nil && v > 0 {
n = v
}
}
fmt.Println("Derived receiving addresses (compare against your wallet for this xpub):")
for i := 0; i < n; i++ {
addr, err := wallet.AddressFromAccountXpub(xpub, 0, uint32(i))
if err != nil {
fmt.Fprintf(os.Stderr, "derive %d: %v\n", i, err)
os.Exit(1)
}
fmt.Printf(" m/44'/195'/0'/0/%d -> %s\n", i, addr)
}
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "selfcheck" {
selfcheck()
return
}
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Single fixed receiving address: either given directly, or derived as index 0
// of the watch-only account xpub (= wallet A address 0).
receiveAddr := os.Getenv("PAY_RECEIVE_ADDRESS")
if receiveAddr == "" {
xpub := os.Getenv("PAY_ACCOUNT_XPUB")
if xpub == "" {
log.Error("set PAY_RECEIVE_ADDRESS, or PAY_ACCOUNT_XPUB to derive address 0")
os.Exit(1)
}
a, err := wallet.AddressFromAccountXpub(xpub, 0, 0)
if err != nil {
log.Error("derive receive address from xpub", "err", err)
os.Exit(1)
}
receiveAddr = a
}
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{ReceiveAddress: receiveAddr, OrderTTL: 15 * time.Minute})
fetcher := tron.NewClient(env("TRONGRID_BASE", ""), env("USDT_CONTRACT", ""), os.Getenv("TRONGRID_API_KEY"))
w := watcher.New(st, fetcher, receiveAddr, log)
log.Info("receiving address", "address", receiveAddr)
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")
}
+255
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+91
View File
@@ -0,0 +1,91 @@
// 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 {
UserRef string `json:"user_ref"`
SKU string `json:"sku"`
Amount int64 `json:"amount"` // base price, 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.UserRef, req.SKU, req.Amount)
if errors.Is(err, pay.ErrUserHasActiveOrder) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "user already has an active order"})
return
}
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))
}
+64
View File
@@ -0,0 +1,64 @@
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 recvAddr = "TRecv00000000000000000000000000000A"
func TestCreateGetAndConflict(t *testing.T) {
st, _ := store.Open(":memory:")
t.Cleanup(func() { _ = st.Close() })
srv := httptest.NewServer(New(pay.New(st, pay.Config{ReceiveAddress: recvAddr})))
t.Cleanup(srv.Close)
body, _ := json.Marshal(map[string]any{"user_ref": "u1", "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 != recvAddr || created.Status != "pending" {
t.Fatalf("create resp: %+v", created)
}
if created.ExpectAmount <= 5_000000 {
t.Fatalf("expect_amount %d should be base+unique tail", created.ExpectAmount)
}
r2, _ := http.Get(srv.URL + "/order/" + created.OrderNo)
if r2.StatusCode != http.StatusOK {
t.Fatalf("get status %d", r2.StatusCode)
}
_ = r2.Body.Close()
// Same user again -> 409 Conflict.
r3, _ := http.Post(srv.URL+"/order", "application/json", bytes.NewReader(body))
if r3.StatusCode != http.StatusConflict {
t.Fatalf("want 409 for second active order, got %d", r3.StatusCode)
}
_ = r3.Body.Close()
r4, _ := http.Get(srv.URL + "/order/NOPE")
if r4.StatusCode != http.StatusNotFound {
t.Fatalf("want 404, got %d", r4.StatusCode)
}
_ = r4.Body.Close()
r5, _ := http.Post(srv.URL+"/order", "application/json", bytes.NewReader([]byte(`{"user_ref":"u2","sku":"x","amount":0}`)))
if r5.StatusCode != http.StatusBadRequest {
t.Fatalf("want 400 for bad amount, got %d", r5.StatusCode)
}
_ = r5.Body.Close()
}
+131
View File
@@ -0,0 +1,131 @@
// Package pay is the order service: create a payment (single fixed receiving
// address + a unique amount) and look one up. Orders are distinguished by the
// unique amount, so one receiving address serves all of them.
package pay
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"time"
"github.com/wangjia/pangolin/pay/internal/store"
)
type Config struct {
ReceiveAddress string // the single fixed receiving address (wallet A addr 0)
OrderTTL time.Duration // how long a pending order stays payable (default 15m)
TailMax int64 // unique-amount tail range [1,TailMax] micro-USDT (default 9999, <0.01 USDT)
AmountCooldown time.Duration // an amount stays reserved this long against reuse; must exceed OrderTTL (default 30m)
}
// ErrUserHasActiveOrder is returned when a user already has a pending order.
var ErrUserHasActiveOrder = errors.New("pay: user already has an active order")
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
}
if cfg.TailMax <= 0 {
cfg.TailMax = 9999
}
if cfg.AmountCooldown <= 0 {
cfg.AmountCooldown = 30 * time.Minute
}
return &Service{st: st, cfg: cfg, now: time.Now}
}
// CreateOrder records a pending order for userRef at base price priceMicro
// (micro-USDT), assigning a unique amount (base + tail) that no recent order
// shares — so a stale payment can never match a new order. One active order per
// user is enforced.
func (s *Service) CreateOrder(ctx context.Context, userRef, sku string, priceMicro int64) (*store.Order, error) {
if userRef == "" {
return nil, fmt.Errorf("pay: userRef required")
}
if sku == "" {
return nil, fmt.Errorf("pay: sku required")
}
if priceMicro <= 0 {
return nil, fmt.Errorf("pay: price must be positive")
}
if s.cfg.ReceiveAddress == "" {
return nil, fmt.Errorf("pay: receive address not configured")
}
// One active order per user.
if _, err := s.st.ActiveOrderByUser(ctx, userRef); err == nil {
return nil, ErrUserHasActiveOrder
} else if !errors.Is(err, store.ErrNotFound) {
return nil, fmt.Errorf("pay: check active order: %w", err)
}
now := s.now()
amount, err := s.allocateAmount(ctx, priceMicro, now)
if err != nil {
return nil, err
}
o := &store.Order{
OrderNo: newOrderNo(now),
UserRef: userRef,
SKU: sku,
ExpectAmount: amount,
Address: s.cfg.ReceiveAddress,
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
}
// allocateAmount picks base+tail such that the amount wasn't used within the
// cooldown window (keeps concurrent + recently-expired amounts distinct).
func (s *Service) allocateAmount(ctx context.Context, base int64, now time.Time) (int64, error) {
since := now.Add(-s.cfg.AmountCooldown).Unix()
for attempt := 0; attempt < 64; attempt++ {
tail, err := randInt(s.cfg.TailMax) // [1, TailMax]
if err != nil {
return 0, err
}
amount := base + tail
used, err := s.st.AmountRecentlyUsed(ctx, amount, since)
if err != nil {
return 0, fmt.Errorf("pay: amount check: %w", err)
}
if !used {
return amount, nil
}
}
return 0, fmt.Errorf("pay: could not allocate a unique amount (too many concurrent orders at this price?)")
}
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)
}
// randInt returns a uniform integer in [1, max].
func randInt(max int64) (int64, error) {
n, err := rand.Int(rand.Reader, big.NewInt(max))
if err != nil {
return 0, err
}
return n.Int64() + 1, nil
}
+77
View File
@@ -0,0 +1,77 @@
package pay
import (
"context"
"errors"
"testing"
"github.com/wangjia/pangolin/pay/internal/store"
)
const recvAddr = "TRecv00000000000000000000000000000A"
func newSvc(t *testing.T) *Service {
t.Helper()
st, err := store.Open(":memory:")
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return New(st, Config{ReceiveAddress: recvAddr})
}
func TestCreateOrderUniqueAmountSameAddress(t *testing.T) {
svc := newSvc(t)
ctx := context.Background()
o1, err := svc.CreateOrder(ctx, "u1", "pro-year", 5_000000)
if err != nil {
t.Fatalf("order1: %v", err)
}
if o1.Address != recvAddr {
t.Fatalf("addr %s, want single receive address", o1.Address)
}
if o1.ExpectAmount <= 5_000000 || o1.ExpectAmount > 5_000000+9999 {
t.Fatalf("amount %d not base+tail(<=9999)", o1.ExpectAmount)
}
o2, err := svc.CreateOrder(ctx, "u2", "pro-year", 5_000000)
if err != nil {
t.Fatalf("order2: %v", err)
}
if o2.ExpectAmount == o1.ExpectAmount {
t.Fatal("amounts must be unique across concurrent orders")
}
if o2.Address != o1.Address {
t.Fatal("single-address model: both orders share the receiving address")
}
}
func TestCreateOrderOneActivePerUser(t *testing.T) {
svc := newSvc(t)
ctx := context.Background()
if _, err := svc.CreateOrder(ctx, "u1", "pro", 100); err != nil {
t.Fatal(err)
}
_, err := svc.CreateOrder(ctx, "u1", "pro", 100)
if !errors.Is(err, ErrUserHasActiveOrder) {
t.Fatalf("want ErrUserHasActiveOrder, got %v", err)
}
if _, err := svc.CreateOrder(ctx, "u2", "pro", 100); err != nil {
t.Fatalf("different user should be allowed: %v", err)
}
}
func TestCreateOrderRejectsBadInput(t *testing.T) {
svc := newSvc(t)
ctx := context.Background()
if _, err := svc.CreateOrder(ctx, "", "pro", 100); err == nil {
t.Fatal("empty userRef should error")
}
if _, err := svc.CreateOrder(ctx, "u1", "", 100); err == nil {
t.Fatal("empty sku should error")
}
if _, err := svc.CreateOrder(ctx, "u1", "pro", 0); err == nil {
t.Fatal("non-positive price should error")
}
}
+205
View File
@@ -0,0 +1,205 @@
// Package store persists pay orders + orphan payments in SQLite (pure-Go
// modernc driver, no CGO — same choice as the control plane).
//
// Model: single fixed receiving address + a unique amount per order. Orders are
// matched by (amount == expect_amount) and (payment block time > order created),
// so a payment can never be misattributed to a later order that happens to share
// the same address.
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 micro-USDT (1e-6), matching the raw
// integer value of a TRC20 USDT transfer (USDT has 6 decimals). ExpectAmount is
// the *unique* amount (base price + a small unique tail).
type Order struct {
OrderNo string
UserRef string
SKU string
ExpectAmount int64
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
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,
user_ref TEXT NOT NULL,
sku TEXT NOT NULL,
expect_amount 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 INDEX IF NOT EXISTS idx_orders_amount_created ON pay_orders(expect_amount, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_orders_user_status ON pay_orders(user_ref, status)`,
`CREATE TABLE IF NOT EXISTS orphan_payments(
tx_id TEXT PRIMARY KEY,
address TEXT NOT NULL,
value INTEGER NOT NULL,
block_ts INTEGER NOT NULL,
created_at INTEGER NOT NULL,
handled INTEGER NOT NULL DEFAULT 0
)`,
}
for _, q := range stmts {
if _, err := s.db.Exec(q); err != nil {
return err
}
}
return nil
}
func (s *Store) CreateOrder(ctx context.Context, o *Order) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO pay_orders(order_no,user_ref,sku,expect_amount,address,status,created_at,expires_at)
VALUES(?,?,?,?,?,?,?,?)`,
o.OrderNo, o.UserRef, o.SKU, o.ExpectAmount, o.Address, o.Status, o.CreatedAt.Unix(), o.ExpiresAt.Unix())
return err
}
const cols = `order_no,user_ref,sku,expect_amount,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.UserRef, &o.SKU, &o.ExpectAmount, &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()
}
// ActiveOrderByUser returns the user's pending order, or (nil, ErrNotFound) if
// none — used to enforce "one active order per user".
func (s *Store) ActiveOrderByUser(ctx context.Context, userRef string) (*Order, error) {
row := s.db.QueryRowContext(ctx, `SELECT `+cols+` FROM pay_orders WHERE user_ref=? AND status=? LIMIT 1`, userRef, StatusPending)
o, err := scanOrder(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return o, err
}
// AmountRecentlyUsed reports whether any order with this expect_amount was
// created at/after sinceUnix — used to keep the unique amount collision-free
// within the late-payment window (so a stale payment can't match a new order).
func (s *Store) AmountRecentlyUsed(ctx context.Context, amount, sinceUnix int64) (bool, error) {
var n int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM pay_orders WHERE expect_amount=? AND created_at>=?`, amount, sinceUnix).Scan(&n)
return n > 0, err
}
// MarkPaid transitions pending->paid idempotently (only affects a still-pending
// row). Returns true if this call 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
}
// TxHandled reports whether a tx id has already been consumed — either matched
// to an order (pay_orders.tx_id) or recorded as an orphan. Guards idempotency.
func (s *Store) TxHandled(ctx context.Context, txID string) (bool, error) {
var n int
err := s.db.QueryRowContext(ctx,
`SELECT (SELECT COUNT(*) FROM pay_orders WHERE tx_id=?) + (SELECT COUNT(*) FROM orphan_payments WHERE tx_id=?)`,
txID, txID).Scan(&n)
return n > 0, err
}
// RecordOrphan stores a payment that matched no active order (wrong amount / late
// after the address was reused). Idempotent on tx_id. Needs manual reconciliation.
func (s *Store) RecordOrphan(ctx context.Context, txID, address string, value, blockTs int64, now time.Time) error {
_, err := s.db.ExecContext(ctx,
`INSERT OR IGNORE INTO orphan_payments(tx_id,address,value,block_ts,created_at) VALUES(?,?,?,?,?)`,
txID, address, value, blockTs, now.Unix())
return err
}
+131
View File
@@ -0,0 +1,131 @@
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 mkOrder(no, user string, amount int64, addr string, now time.Time) *Order {
return &Order{
OrderNo: no, UserRef: user, SKU: "pro", ExpectAmount: amount, Address: addr,
Status: StatusPending, CreatedAt: now, ExpiresAt: now.Add(15 * time.Minute),
}
}
func TestOrderRoundtripAndMarkPaidIdempotent(t *testing.T) {
s := openMem(t)
ctx := context.Background()
now := time.Unix(1_700_000_000, 0)
if err := s.CreateOrder(ctx, mkOrder("PAY1", "u1", 5_000017, "TADDR", now)); err != nil {
t.Fatalf("create: %v", err)
}
got, err := s.GetOrder(ctx, "PAY1")
if err != nil {
t.Fatalf("get: %v", err)
}
if got.UserRef != "u1" || got.ExpectAmount != 5_000017 || got.Status != StatusPending {
t.Fatalf("roundtrip: %+v", got)
}
ok, err := s.MarkPaid(ctx, "PAY1", "tx-a")
if err != nil || !ok {
t.Fatalf("first MarkPaid ok=%v err=%v", ok, err)
}
ok2, err := s.MarkPaid(ctx, "PAY1", "tx-b")
if err != nil || ok2 {
t.Fatalf("second MarkPaid ok=%v err=%v (want false)", ok2, err)
}
got, _ = s.GetOrder(ctx, "PAY1")
if got.Status != StatusPaid || got.TxID != "tx-a" {
t.Fatalf("after paid: %s / %s", got.Status, got.TxID)
}
}
func TestActiveOrderByUser(t *testing.T) {
s := openMem(t)
ctx := context.Background()
now := time.Unix(1_700_000_000, 0)
_ = s.CreateOrder(ctx, mkOrder("PAY1", "u1", 100, "T", now))
o, err := s.ActiveOrderByUser(ctx, "u1")
if err != nil || o.OrderNo != "PAY1" {
t.Fatalf("u1 active: %v %v", o, err)
}
if _, err := s.ActiveOrderByUser(ctx, "u2"); err != ErrNotFound {
t.Fatalf("u2 want ErrNotFound, got %v", err)
}
_, _ = s.MarkPaid(ctx, "PAY1", "tx")
if _, err := s.ActiveOrderByUser(ctx, "u1"); err != ErrNotFound {
t.Fatalf("paid should not be active: %v", err)
}
}
func TestAmountRecentlyUsed(t *testing.T) {
s := openMem(t)
ctx := context.Background()
now := time.Unix(1_700_000_000, 0)
_ = s.CreateOrder(ctx, mkOrder("PAY1", "u1", 5_000017, "T", now))
since := now.Add(-30 * time.Minute).Unix()
if used, _ := s.AmountRecentlyUsed(ctx, 5_000017, since); !used {
t.Fatal("5_000017 should be recently used")
}
if used, _ := s.AmountRecentlyUsed(ctx, 5_000018, since); used {
t.Fatal("5_000018 not used")
}
if used, _ := s.AmountRecentlyUsed(ctx, 5_000017, now.Add(time.Minute).Unix()); used {
t.Fatal("outside window should be false")
}
}
func TestTxHandledAndOrphan(t *testing.T) {
s := openMem(t)
ctx := context.Background()
now := time.Unix(1_700_000_000, 0)
_ = s.CreateOrder(ctx, mkOrder("PAY1", "u1", 100, "T", now))
if h, _ := s.TxHandled(ctx, "tx-x"); h {
t.Fatal("tx-x should be unhandled")
}
_, _ = s.MarkPaid(ctx, "PAY1", "tx-x")
if h, _ := s.TxHandled(ctx, "tx-x"); !h {
t.Fatal("matched tx should be handled")
}
if err := s.RecordOrphan(ctx, "tx-o", "T", 999, now.Unix(), now); err != nil {
t.Fatalf("orphan: %v", err)
}
if h, _ := s.TxHandled(ctx, "tx-o"); !h {
t.Fatal("orphan tx should be handled")
}
if err := s.RecordOrphan(ctx, "tx-o", "T", 999, now.Unix(), now); err != nil {
t.Fatalf("orphan idempotent: %v", err)
}
}
func TestMarkExpired(t *testing.T) {
s := openMem(t)
ctx := context.Background()
base := time.Unix(1_700_000_000, 0)
_ = s.CreateOrder(ctx, &Order{OrderNo: "old", UserRef: "u1", SKU: "x", ExpectAmount: 1, Address: "T", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(time.Minute)})
_ = s.CreateOrder(ctx, &Order{OrderNo: "new", UserRef: "u2", SKU: "x", ExpectAmount: 2, Address: "T", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(time.Hour)})
n, err := s.MarkExpired(ctx, base.Add(10*time.Minute))
if err != nil || n != 1 {
t.Fatalf("MarkExpired n=%d err=%v", n, err)
}
o1, _ := s.GetOrder(ctx, "old")
o2, _ := s.GetOrder(ctx, "new")
if o1.Status != StatusExpired || o2.Status != StatusPending {
t.Fatalf("old=%s new=%s", o1.Status, o2.Status)
}
}
+41
View File
@@ -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:]
}
+95
View File
@@ -0,0 +1,95 @@
// 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).
// BlockTs is the on-chain block time in **unix seconds** — used to reject a
// payment that arrived before the order it might match was created.
type Transfer struct {
TxID string
To string
Value int64
BlockTs 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"`
BlockTimestamp int64 `json:"block_timestamp"` // milliseconds
} `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, BlockTs: d.BlockTimestamp / 1000})
}
return out, nil
}
+74
View File
@@ -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
}
+76
View File
@@ -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")
}
}
+164
View File
@@ -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
}
+39
View File
@@ -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
}
+39
View File
@@ -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)
}
}
+78
View File
@@ -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)
}
+99
View File
@@ -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
}
)
+109
View File
@@ -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
}
+112
View File
@@ -0,0 +1,112 @@
// Package watcher polls TronGrid for incoming USDT to the single receiving
// address and matches each confirmed payment to a pending order by exact amount
// + block time. 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
address string
log *slog.Logger
now func() time.Time
}
func New(st *store.Store, f tron.Fetcher, address string, log *slog.Logger) *Watcher {
if log == nil {
log = slog.Default()
}
return &Watcher{st: st, tron: f, address: address, log: log, now: time.Now}
}
// Tick:
// 1. expire overdue pending orders;
// 2. fetch confirmed incoming USDT transfers to the single receiving address;
// 3. match each transfer to a pending order by **exact amount** and **block time
// after the order was created**; a confirmed transfer that matches no active
// order (wrong amount / late after reuse) is recorded as an orphan.
//
// Idempotent: a tx already matched to an order or recorded as orphan is skipped.
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)
}
transfers, err := w.tron.IncomingTransfers(ctx, w.address)
if err != nil {
w.log.Warn("fetch transfers failed", "err", err)
return nil // transient (rate limit / network); retried next tick
}
if len(transfers) == 0 {
return nil
}
pending, err := w.st.ListPending(ctx)
if err != nil {
return err
}
// Index pending orders by their unique expect amount.
byAmount := make(map[int64]*store.Order, len(pending))
for _, o := range pending {
byAmount[o.ExpectAmount] = o
}
for _, t := range transfers {
handled, err := w.st.TxHandled(ctx, t.TxID)
if err != nil {
w.log.Error("tx handled check", "tx", t.TxID, "err", err)
continue
}
if handled {
continue // already matched or already an orphan
}
if o := byAmount[t.Value]; o != nil && t.BlockTs > o.CreatedAt.Unix() {
ok, err := w.st.MarkPaid(ctx, o.OrderNo, t.TxID)
if err != nil {
w.log.Error("mark paid", "order", o.OrderNo, "err", err)
continue
}
if ok {
w.log.Info("order paid", "order", o.OrderNo, "tx", t.TxID, "value", t.Value)
delete(byAmount, t.Value) // a second transfer of the same amount can't reuse this order
}
continue
}
// Confirmed payment matching no active order -> orphan (needs reconciliation).
if err := w.st.RecordOrphan(ctx, t.TxID, w.address, t.Value, t.BlockTs, w.now()); err != nil {
w.log.Error("record orphan", "tx", t.TxID, "err", err)
continue
}
w.log.Warn("orphan payment", "tx", t.TxID, "value", t.Value, "block_ts", t.BlockTs)
}
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)
}
}
}
}
+167
View File
@@ -0,0 +1,167 @@
package watcher
import (
"context"
"testing"
"time"
"github.com/wangjia/pangolin/pay/internal/store"
"github.com/wangjia/pangolin/pay/internal/tron"
)
const recvAddr = "TRecv00000000000000000000000000000A"
type mockFetcher struct{ transfers []tron.Transfer }
func (f *mockFetcher) IncomingTransfers(_ context.Context, _ string) ([]tron.Transfer, error) {
return f.transfers, nil
}
func memStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(":memory:")
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func seed(t *testing.T, st *store.Store, no string, amount int64, created time.Time) {
t.Helper()
o := &store.Order{
OrderNo: no, UserRef: "u", SKU: "pro", ExpectAmount: amount, Address: recvAddr,
Status: store.StatusPending, CreatedAt: created, ExpiresAt: created.Add(time.Hour),
}
if err := st.CreateOrder(context.Background(), o); err != nil {
t.Fatalf("seed: %v", err)
}
}
func TestWatcherMatchesByAmountAndTime(t *testing.T) {
st := memStore(t)
ctx := context.Background()
now := time.Unix(1_700_000_100, 0)
created := now.Add(-5 * time.Minute)
seed(t, st, "PAY1", 5_000017, created)
seed(t, st, "PAY2", 5_000018, created)
fetch := &mockFetcher{transfers: []tron.Transfer{
{TxID: "tx1", To: recvAddr, Value: 5_000017, BlockTs: created.Add(time.Minute).Unix()},
}}
w := New(st, fetch, recvAddr, nil)
w.now = func() time.Time { return now }
if err := w.Tick(ctx); err != nil {
t.Fatalf("tick: %v", err)
}
o1, _ := st.GetOrder(ctx, "PAY1")
if o1.Status != store.StatusPaid || o1.TxID != "tx1" {
t.Fatalf("PAY1 %s/%s", o1.Status, o1.TxID)
}
o2, _ := st.GetOrder(ctx, "PAY2")
if o2.Status != store.StatusPending {
t.Fatalf("PAY2 should stay pending, got %s", o2.Status)
}
if err := w.Tick(ctx); err != nil { // idempotent
t.Fatalf("tick2: %v", err)
}
o1, _ = st.GetOrder(ctx, "PAY1")
if o1.Status != store.StatusPaid || o1.TxID != "tx1" {
t.Fatal("idempotency broken")
}
}
func TestWatcherWrongAmountIsOrphan(t *testing.T) {
st := memStore(t)
ctx := context.Background()
now := time.Unix(1_700_000_100, 0)
created := now.Add(-5 * time.Minute)
seed(t, st, "PAY1", 5_000017, created)
fetch := &mockFetcher{transfers: []tron.Transfer{
{TxID: "tx-wrong", To: recvAddr, Value: 5_000000, BlockTs: created.Add(time.Minute).Unix()},
}}
w := New(st, fetch, recvAddr, nil)
w.now = func() time.Time { return now }
_ = w.Tick(ctx)
o, _ := st.GetOrder(ctx, "PAY1")
if o.Status != store.StatusPending {
t.Fatalf("PAY1 should stay pending, got %s", o.Status)
}
if h, _ := st.TxHandled(ctx, "tx-wrong"); !h {
t.Fatal("wrong-amount payment should be recorded as orphan")
}
}
func TestWatcherLatePaymentDoesNotMatchNewOrder(t *testing.T) {
// Order1 (amount 5_000017) expired; a NEW order (amount 5_000018) is now active
// on the SAME address. A late payment of the OLD amount must NOT match the new
// order (different amount) -> orphan.
st := memStore(t)
ctx := context.Background()
now := time.Unix(1_700_000_500, 0)
seed(t, st, "PAY2", 5_000018, now.Add(-time.Minute))
fetch := &mockFetcher{transfers: []tron.Transfer{
{TxID: "tx-late", To: recvAddr, Value: 5_000017, BlockTs: now.Unix()},
}}
w := New(st, fetch, recvAddr, nil)
w.now = func() time.Time { return now }
_ = w.Tick(ctx)
o2, _ := st.GetOrder(ctx, "PAY2")
if o2.Status != store.StatusPending {
t.Fatalf("PAY2 must not be matched by a wrong-amount late payment, got %s", o2.Status)
}
if h, _ := st.TxHandled(ctx, "tx-late"); !h {
t.Fatal("late payment should be orphan")
}
}
func TestWatcherIgnoresPaymentBeforeOrder(t *testing.T) {
// A payment whose block time is BEFORE the order was created must not match
// (guards address reuse: prior balance / old tx).
st := memStore(t)
ctx := context.Background()
now := time.Unix(1_700_000_500, 0)
created := now.Add(-2 * time.Minute)
seed(t, st, "PAY1", 5_000017, created)
fetch := &mockFetcher{transfers: []tron.Transfer{
{TxID: "tx-old", To: recvAddr, Value: 5_000017, BlockTs: created.Add(-time.Minute).Unix()},
}}
w := New(st, fetch, recvAddr, nil)
w.now = func() time.Time { return now }
_ = w.Tick(ctx)
o, _ := st.GetOrder(ctx, "PAY1")
if o.Status != store.StatusPending {
t.Fatalf("payment before order must not match, got %s", o.Status)
}
if h, _ := st.TxHandled(ctx, "tx-old"); !h {
t.Fatal("pre-order payment should be orphan")
}
}
func TestWatcherExpires(t *testing.T) {
st := memStore(t)
ctx := context.Background()
now := time.Unix(1_700_000_500, 0)
o := &store.Order{
OrderNo: "OLD", UserRef: "u", SKU: "pro", ExpectAmount: 1, Address: recvAddr,
Status: store.StatusPending, CreatedAt: now.Add(-time.Hour), ExpiresAt: now.Add(-time.Minute),
}
_ = st.CreateOrder(ctx, o)
w := New(st, &mockFetcher{}, recvAddr, nil)
w.now = func() time.Time { return now }
_ = w.Tick(ctx)
got, _ := st.GetOrder(ctx, "OLD")
if got.Status != store.StatusExpired {
t.Fatalf("want expired, got %s", got.Status)
}
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# selfcheck.sh — 从 Bitwarden 取钱包 A 的 account xpub(公钥),跑 paywatch selfcheck,
# 打印前 N 个派生收款地址,供与你钱包(TronLink 插件)/ Ian Coleman 的 m/44'/195'/0'/0/i
# 逐个核对。全部一致 = 派生对齐、收款地址是你控制的。
#
# 用法:
# bash pay/scripts/selfcheck.sh [BW条目名] [地址个数]
# 例: bash pay/scripts/selfcheck.sh pangolin-pay-xpub 5
#
# xpub 是公钥、不敏感,但仍统一走 Bitwarden,不写死在脚本/仓库里。
# 私钥/助记词永远不进这里,也不该在任何联网机上出现。
set -euo pipefail
# ① Bitwarden 条目名:改成你存 xpub 的那个条目(或用第 1 个参数覆盖)。
ITEM="${1:-${PAY_XPUB_ITEM:-pangolin-pay-xpub}}"
# ② 打印几个地址(默认 5)。
COUNT="${2:-5}"
# ③ 若 xpub 存在自定义字段(不是默认密码字段),设 PAY_XPUB_FIELD=字段名。
FIELD="${PAY_XPUB_FIELD:-}"
# 切到 pay 模块目录(脚本在 pay/scripts/ 下),不依赖当前工作目录。
here="${BASH_SOURCE[0]%/*}"
[ "$here" = "${BASH_SOURCE[0]}" ] && here="."
cd "$here/.."
# 确保 Bitwarden 已解锁(rbw)。
if ! rbw unlocked >/dev/null 2>&1; then
rbw unlock
fi
# 取 xpub → 直接喂给 selfcheck(不落盘、不进 shell history)。
if [ -n "$FIELD" ]; then
rbw get --field "$FIELD" "$ITEM"
else
rbw get "$ITEM"
fi | {
IFS= read -r xpub || true
if [ -z "${xpub:-}" ]; then
echo "selfcheck: 未从 Bitwarden 条目 '$ITEM' 取到 xpub(核对条目名/字段)" >&2
exit 1
fi
PAY_ACCOUNT_XPUB="$xpub" go run ./cmd/paywatch selfcheck "$COUNT"
}
+9
View File
@@ -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`} />
+1 -1
View File
@@ -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>
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -54
View File
@@ -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();
+1 -1
View File
@@ -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>
+22 -10
View File
@@ -12,7 +12,7 @@ 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';
@@ -65,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);
}
}
@@ -120,12 +124,6 @@ export default function UserCenter() {
setView('overview');
}
// 静态导出无服务端会话:首屏(!ready)与未登录一律直接渲染登录页,避免出现空白
// 背景(慢网络下用户会看到"空的")。已登录用户(有 refresh)会话续期完成后再切面板。
if (!ready || !authed) {
return <Login onDone={onLoginDone} />;
}
const nav: [View, string, string][] = [
['overview', 'layout-dashboard', t('navOverview')],
['sub', 'link', t('navSub')],
@@ -161,7 +159,10 @@ 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)' }}>
@@ -180,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>
@@ -193,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);
}
+4 -1
View File
@@ -161,11 +161,14 @@ 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);
+29 -1
View File
@@ -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() {
@@ -33,6 +33,8 @@ export default function Header({ lang = 'zh', t = {} }) {
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);
@@ -61,6 +63,24 @@ export default function Header({ lang = 'zh', t = {} }) {
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); };
@@ -113,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"
+4 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+2
View File
@@ -57,6 +57,8 @@ const metaTitle = `${title} · ${t('nav.brand')}`;
<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" />
+2
View File
@@ -70,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" />
+6 -1
View File
@@ -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}