Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5055afdbd7 | |||
| fd285708c7 | |||
| e169099524 | |||
| efa6cffecd | |||
| 7cf87a764f | |||
| 4bb92209ca | |||
| 4393edf1d7 | |||
| 9a8fec2e9c | |||
| 101b31e073 | |||
| 11ad888ee7 | |||
| 53d8ded7f6 | |||
| e3e1d43d5d | |||
| 09b1944376 | |||
| e0912dd819 | |||
| f4c5f93370 | |||
| 6dcf582225 | |||
| 9ed7588ec0 | |||
| 87647cae34 | |||
| 620d8148f3 |
@@ -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/workflows:tag 触发(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-address-pool-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-address-pool.md</code>(含 checkbox)。从"每单唯一 HD 地址"改为<b>地址池复用</b>:池大小=峰值并行度(初始 10,不够动态派生),地址复用摊薄激活/归集,归集成本 O(订单数)→O(池大小)。规则:最小编号 idle 绑单、无 idle 派生新址、15min 超时释放、同用户单订单。核心安全点:地址复用的<b>迟到付款错配</b>——匹配按"到账 tx+金额+时间戳(晚于建单)"、每单唯一金额、孤儿付款记录。5 Phase:数据模型→建单→watcher 匹配/释放/孤儿→归集(能量租赁)→并发/复用真链验证。</div>
|
||||
<div class="path">docs/pay-address-pool-plan.html · 真相源 docs/superpowers/plans/2026-07-09-pay-address-pool.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,售出才产合法码)。复用已就绪 codes(redeem/批次/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>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<!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}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:12.5px;line-height:1.5;color:#cdd3df}
|
||||
.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}
|
||||
.warnbox{background:rgba(224,106,106,.08);border:1px solid rgba(224,106,106,.35);border-radius:12px;padding:14px 18px;margin:14px 0}
|
||||
.warnbox b{color:var(--bad)}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:13.5px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||
</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-address-pool.md</code> · #34/34A 收款模型修订(折中方案)</p>
|
||||
|
||||
<div class="lead">
|
||||
从"每单唯一 HD 地址"改为<b>地址池复用</b>:池大小 = <b>峰值并行度</b>(初始 10,不够动态派生),地址复用摊薄激活/归集成本;仍多地址,<b>避免并发订单金额撞、对账精确</b>。归集成本从 O(订单数) 降到 <b>O(池大小)</b>,激活一次/地址即长期复用。是"单地址(省钱撞单)"与"每单唯一(精确但贵)"的折中。
|
||||
</div>
|
||||
|
||||
<div class="warnbox">
|
||||
<b>核心风险 · 地址复用的迟到付款错配:</b>地址回收后绑给新订单,若前一个用户超时后才付款,钱打到已绑新订单的地址上 → 可能误配。<b>解法(必须):</b>① 匹配按"到账 tx + 金额 + 时间戳(晚于建单)",<b>不按余额、不只按地址</b>;② <b>每单唯一金额</b>(基准+唯一尾数);③ 匹配不到活跃订单的到账 → 记 <code>orphan_payments</code> 人工对账;④(可选)超时后地址冷却再重绑。
|
||||
</div>
|
||||
|
||||
<h2>设计(规则 + 安全点)</h2>
|
||||
<ul>
|
||||
<li><b>地址池</b>:HD 派生地址(<code>m/44'/195'/0'/0/i</code>),状态 <code>idle|busy</code>,初始 10。</li>
|
||||
<li><b>建单</b>:绑<b>最小编号 idle</b> 地址→busy;无 idle → 派生新地址入池。<b>原子</b>(事务/行锁,防并发抢同址)。</li>
|
||||
<li><b>每单唯一金额</b> ⭐:基准 + 唯一尾数,当前活跃订单间不撞;复用地址上旧单迟到付款不误配新单。</li>
|
||||
<li><b>15min 超时</b> → expired → 地址回 idle(可选冷却)→ 提示重建订单。</li>
|
||||
<li><b>付款完成</b> → paid → 地址回 idle(注意:地址里仍有 USDT 待归集,idle 只表示可重绑)。</li>
|
||||
<li><b>匹配</b>:找"到该地址 + 金额命中 + 区块时间晚于建单"的转入 tx。</li>
|
||||
<li><b>同用户单订单</b>:同时只能一个活跃订单。</li>
|
||||
<li><b>归集</b>:定期扫池内有余额地址→冷钱包(能量租赁 ~$0.1–1/笔);池 ~10,笔数有界;与 busy 解耦。</li>
|
||||
</ul>
|
||||
|
||||
<h2>Phase A — 数据模型</h2>
|
||||
<ul class="ck">
|
||||
<li><b>A.1</b> <code>addresses(id, addr_index唯一, address, state, activated, created_at)</code>,按需派生入池。</li>
|
||||
<li><b>A.2</b> <code>pay_orders</code> 加 <code>user_ref / address_id / expect_amount(唯一金额) / matched_tx_id</code>。</li>
|
||||
<li><b>A.3</b> <code>orphan_payments(tx_id唯一, address, value, block_ts, handled)</code>。</li>
|
||||
<li><b>A.4</b> 复用 addr_cursor 派生新址;迁移 + 索引(status/address/user_ref)。</li>
|
||||
</ul>
|
||||
|
||||
<h2>Phase B — 建单</h2>
|
||||
<ul class="ck">
|
||||
<li><b>B.1</b> CreateOrder(user, sku):校验同用户无活跃单 → 原子选最小 idle 置 busy(无则派生)→ 分配唯一金额 → 写 pending(TTL 15min)。</li>
|
||||
<li><b>B.2</b> 唯一金额分配(基准+唯一尾数,当前活跃单间不撞)。</li>
|
||||
<li><b>B.3</b> GetOrder + releaseAddress 公共方法(paid/expired 复用)。</li>
|
||||
</ul>
|
||||
|
||||
<h2>Phase C — watcher(匹配/释放/孤儿)</h2>
|
||||
<ul class="ck">
|
||||
<li><b>C.1</b> 匹配:pending 单查其地址转入,value 命中唯一金额 且 <code>block_ts>created</code> → paid + 记 tx + 释放地址。</li>
|
||||
<li><b>C.2</b> 超时:pending 过期 → expired + 释放地址(可选冷却)。</li>
|
||||
<li><b>C.3</b> 孤儿:匹配不到活跃订单的到账 → 记 orphan_payments(去重)。</li>
|
||||
<li><b>C.4</b> 幂等(tx_id 去重)+ 崩溃恢复;TronGrid 取 block_timestamp 供时间过滤。</li>
|
||||
</ul>
|
||||
|
||||
<h2>Phase D — 归集</h2>
|
||||
<ul class="ck">
|
||||
<li><b>D.1</b> 扫池内有余额地址 → 冷钱包(能量租赁,先手动 runbook);与订单状态解耦。</li>
|
||||
</ul>
|
||||
|
||||
<h2>Phase E — 测试 + 真链</h2>
|
||||
<ul class="ck">
|
||||
<li><b>E.1</b> 单测:并发不抢同址、复用后迟到付款不误配、唯一金额、超时释放、孤儿、幂等、崩溃恢复、同用户单订单。</li>
|
||||
<li><b>E.2</b> 真链:两并发订单各绑地址付款;一单超时释放后新单复用同址,旧地址补"旧金额"付款应成孤儿不误配;归集。</li>
|
||||
</ul>
|
||||
|
||||
<h2>不在本轮 / 取舍</h2>
|
||||
<ul>
|
||||
<li>GasFree(#35,免 TRX 但每笔固定 1 USDT,更适合少地址批量,另议)。</li>
|
||||
<li>能量租赁自动化(先手动);池增长上限(先宽松);发货侧(独角数卡 + /internal/codes/mint)另排。</li>
|
||||
</ul>
|
||||
|
||||
<p class="small" style="margin-top:32px">相关:<a href="crypto-tx-engine-plan.html">加密货币交易引擎(已实现钱包/tron/sweep)</a> · <a href="payment-clean-usdt-plan.html">#34 收款闭环全景</a></p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
# pangolin-pay 地址池收款模型(折中方案,取代"每单唯一 HD 地址")
|
||||
|
||||
> #34/34A 收款模型修订。**Context**:每单唯一 HD 地址 → 每地址激活(~1 TRX)+ 归集(每笔一次转账)成本随
|
||||
> **订单总数线性增长**(#34 暴露的严峻成本)。改为**地址池复用**:池大小 = **峰值并行度**(初始 10,不够动态派生),
|
||||
> 地址**复用**摊薄激活/归集;仍是多地址,**避免并发订单金额撞、对账精确**。这是"单地址(省钱但撞单)"与
|
||||
> "每单唯一(精确但贵)"的折中。
|
||||
>
|
||||
> 归集成本从"O(订单数)"降到"O(池大小)";激活成本一次性(每个池地址激活一次即长期复用)。
|
||||
>
|
||||
> **本 plan = 用户定的规则 + 补齐的安全点**(核心:地址复用的**迟到付款错配**)。已实现的 Phase B(钱包派生)、
|
||||
> tron(TronGrid 读到账/建/广播/签名)、cmd/sweep 复用;主要改 store / pay(建单)/ watcher(匹配)。
|
||||
|
||||
## 设计(规则 + 安全点)
|
||||
|
||||
- **地址池**:HD 派生地址(路径 `m/44'/195'/0'/0/i`,与现有一致),各有状态 `idle | busy`。初始池 10。
|
||||
- **建单**:绑**最小编号的 idle** 地址 → 置 `busy`;**无 idle → 派生下一个新地址**(编号递增)入池并绑之。
|
||||
- **每单唯一金额** ⭐必需:基准价 + 唯一尾数(如按订单序号映射的微分),保证**当前活跃订单之间金额不撞**,
|
||||
且复用地址上"旧订单迟到付款"不会误配"新订单"。
|
||||
- **15 分钟超时**:超时 → `expired` → 地址回 `idle`(可选**冷却期**再重绑,降低迟到错配)→ 提示用户**重建订单**、用新订单地址付款。
|
||||
- **付款完成**:watcher 确认到账 → 订单 `paid` → 地址回 `idle`。**注意**:此时地址里**仍有 USDT**(待归集);
|
||||
`idle` 只表示"可重绑",不代表钱清了。
|
||||
- **匹配规则(核心)**:watcher 找**到该地址、金额 == 该单唯一金额(或 ≥,带容差)、且到账区块时间晚于订单创建**
|
||||
的 TRC20 转入 **tx** → 匹配。**绝不按余额、不只按地址**(地址会累积余额、会复用)。
|
||||
- **同用户单订单**:同一用户同时只能有**一个**活跃(pending)订单。
|
||||
- **孤儿付款**:到账 tx 匹配不到任何活跃订单(迟到 / 欠额 / 误差)→ 记 `orphan_payments`,人工对账/补发。
|
||||
- **归集**:定期扫池内**有余额**的地址 → 冷钱包(**能量租赁** ~$0.1–1/笔);池 ~10 地址,**归集笔数有界**;
|
||||
归集与 `busy` 无关(随时可扫,不影响待付订单——到账是未来的独立 tx)。
|
||||
- **池增长上限**(可选):无空闲就新建,设上限防滥用无限增长(超限则排队/拒单)。
|
||||
|
||||
## Phase A — 数据模型改造
|
||||
|
||||
- [ ] `addresses` 表:`id / addr_index(唯一) / address / state('idle'|'busy') / activated(bool) / created_at`;初始不预建,按需派生入池。
|
||||
- [ ] `pay_orders` 改:加 `user_ref`(同用户单订单约束用)、`address_id`(绑定的池地址)、`expect_amount`(唯一金额,micro-USDT)、`matched_tx_id`;保留 status/created_at/expires_at。
|
||||
- [ ] `orphan_payments` 表:`tx_id(唯一) / address / value / block_ts / created_at / handled(bool)`。
|
||||
- [ ] 复用现有 `addr_cursor`(HD 派生游标)派生新池地址。迁移脚本 + 索引(status、address、user_ref)。
|
||||
|
||||
## Phase B — 建单(pay 服务)
|
||||
|
||||
- [ ] `CreateOrder(userRef, sku)`:① 校验**同用户无活跃订单**(有则返回现有/拒);② **原子**选最小编号 `idle` 地址(事务 + 行锁)置 `busy`;③ 无 idle → `NextAddrIndex` 派生新地址(`AddressFromAccountXpub`)入池并绑;④ 分配**唯一金额**;⑤ 写 `pending` 单(TTL 15min)。
|
||||
- [ ] **唯一金额分配**:基准金额 + 唯一尾数,保证与**当前所有活跃订单**不撞(可用订单序号 → 尾数微分,micro-USDT 级);记录基准+尾数便于展示。
|
||||
- [ ] `GetOrder`;释放地址的公共方法 `releaseAddress(addrID)`(paid/expired 复用)。
|
||||
|
||||
## Phase C — watcher 改造(匹配 + 释放 + 孤儿)
|
||||
|
||||
- [ ] **匹配**:对每个 `pending` 单,查其绑定地址的 TRC20 转入;筛 `value` 命中唯一金额(==,或 ≥ 带小容差)**且 `block_ts > order.created_at`** → `paid` + 记 `matched_tx_id`;**释放地址 → idle**。
|
||||
- [ ] **超时**:`pending` 过期 → `expired` + 释放地址 → idle(可选冷却期后才允许重绑)。
|
||||
- [ ] **孤儿**:遍历池地址的到账 tx,凡匹配不到任何活跃订单的转入 → 写 `orphan_payments`(去重 by tx_id)。
|
||||
- [ ] **幂等**(tx_id 全局去重,含 orphan)、崩溃恢复(从 DB pending + 池状态重建)。TronGrid 需能取到账时间戳(`block_timestamp`)用于时间过滤。
|
||||
|
||||
## Phase D — 归集(复用 cmd/sweep)
|
||||
|
||||
- [ ] 扫池内**有余额**地址 → 冷钱包;接**能量租赁**(先手动 runbook,后续自动化);归集与订单状态解耦。
|
||||
- [ ] 归集不清空 `busy` 语义;只搬余额。runbook 写清"哪些地址、多久扫一次、能量怎么租"。
|
||||
|
||||
## Phase E — 测试 + 真链验证
|
||||
|
||||
- [ ] 单测:并发建单**不抢同址**(原子性)、地址**复用后旧订单迟到付款不误配新订单**(唯一金额+时间戳)、唯一金额不撞、超时释放、孤儿记录、幂等、崩溃恢复、同用户单订单约束。
|
||||
- [ ] 真链:两并发订单各绑不同地址各自付款到 paid;一单超时释放后新单**复用同址**,再对旧地址补一笔"旧金额"付款 → 应成**孤儿**、不误配新单;归集池地址到冷钱包。
|
||||
|
||||
## Verification / 判据
|
||||
|
||||
- `go test ./...`(store/pay/watcher 新逻辑全绿,重点覆盖复用错配)。
|
||||
- 真链:并发 + 复用 + 迟到付款三场景表现正确;归集笔数 = 有余额的池地址数(有界)。
|
||||
- 成本:激活一次/地址、归集 O(池大小);对小额高频订单不再被激活费线性拖累。
|
||||
|
||||
## 不在本轮 / 取舍
|
||||
|
||||
- GasFree(#35,免 TRX 但每笔固定 1 USDT,更适合少地址批量,另议)。
|
||||
- 能量租赁自动化(先手动)。
|
||||
- 池增长上限/排队(先不设,或设一个宽上限)。
|
||||
- 发货侧(独角数卡 + /internal/codes/mint)另排。
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
# pangolin-pay
|
||||
|
||||
自托管 USDT(TRC20) 收款服务。给每个订单派生一个**唯一收款地址**(watch-only,从账户
|
||||
xpub 派生,**不持私钥**),轮询 TronGrid 侦测到账、确认后标记订单已付。归集(把钱扫到冷
|
||||
钱包)是**独立的离线步骤**,本服务不碰私钥。
|
||||
|
||||
> 属 #34「独角数卡 + USDT 收款闭环」的加密货币交易引擎(计划见
|
||||
> `docs/superpowers/plans/2026-07-09-crypto-tx-engine.md`)。概念见 brain
|
||||
> `notes/dev/crypto-hd-wallet-basics.html`。
|
||||
|
||||
## 安全模型(必读)
|
||||
|
||||
- 本服务(热、联网)**只持 account xpub**——能派生收款地址、能查到账,**拿不到任何私钥**。被脱库也转不走钱。
|
||||
- 私钥/助记词**冷存**;要动钱(归集)时才在**离线端**用私钥签名(见 seed.go,OFFLINE ONLY)。
|
||||
- 密钥(xpub / TronGrid key)走 Bitwarden,不入 git、不写死。
|
||||
|
||||
## Phase A —— 离线准备钱包(你在断网机器上做)
|
||||
|
||||
1. 断网,用 Ian Coleman `bip39-standalone.html`(或 `bip_utils`)生成**两套** 24 词助记词:
|
||||
钱包 A(运营收款)、钱包 B(冷备金库)。Coin=TRX、English。
|
||||
2. 取**钱包 A 的 Account Extended Public Key**(`m/44'/195'/0'` 的 xpub)→ 就是本服务的 `PAY_ACCOUNT_XPUB`。
|
||||
3. 取**钱包 B 的地址0**(`T...`)→ 归集目标(Phase D 用)。
|
||||
4. **交叉核对(关键)**:本仓自带的金标准向量(测试助记词 `abandon…about`)必须与 Ian Coleman
|
||||
一致 —— 跑 `go test ./internal/wallet -run TestKnownVector -v`,再在 Ian Coleman 里用同一测试
|
||||
助记词、Coin=TRX 对照前 3 个地址。一致 = 派生实现可信;不一致 = 有 bug,别上线。
|
||||
5. 助记词 A/B 分开冷存 + Bitwarden。**只把 xpub_A 交给本服务。**
|
||||
|
||||
## 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 {"sku":"pro-year","amount":5000000} # amount = micro-USDT(1e-6)
|
||||
→ 201 {"order_no","address","expect_amount","status":"pending","expires_at"}
|
||||
GET /order/{orderNo} → 200 {..., "status":"pending|paid|expired","tx_id"}
|
||||
GET /healthz → 200 ok
|
||||
```
|
||||
|
||||
门面(独角数卡)下单时调 `POST /order` 拿收款地址;支付页轮询 `GET /order/{id}` 直到 `paid`。
|
||||
|
||||
## Phase D —— 归集(气隙签名,`cmd/sweep`)
|
||||
|
||||
把散在各收款地址的 USDT 扫到冷钱包,**助记词只在离线机上出现**,联网机永远拿不到私钥。
|
||||
四段式,跨气隙用文件传递(unsigned.json / signed.json):
|
||||
|
||||
```bash
|
||||
# ① 联网:列出有余额的收款地址
|
||||
PAY_ACCOUNT_XPUB=xpub... TRONGRID_API_KEY=... go run ./cmd/sweep plan --max 50
|
||||
|
||||
# ② 联网:构造未签名转账(全额 → 冷钱包),不碰私钥
|
||||
PAY_ACCOUNT_XPUB=xpub... TRONGRID_API_KEY=... \
|
||||
go run ./cmd/sweep build --cold TColdAddr... --max 50 --fee-limit 30000000 > unsigned.json
|
||||
|
||||
# ③ 离线(断网机):从 Bitwarden 取助记词进环境,独立验签后签名
|
||||
PAY_SWEEP_MNEMONIC="word1 ... word24" \
|
||||
go run ./cmd/sweep sign --cold TColdAddr... < unsigned.json > signed.json
|
||||
|
||||
# ④ 联网:广播
|
||||
TRONGRID_API_KEY=... go run ./cmd/sweep broadcast < signed.json
|
||||
```
|
||||
|
||||
**`sign` 独立验签(气隙安全的关键)**——对每笔交易:① 重算 txid=sha256(raw_data) 必须等于声称值
|
||||
(防 raw_data 被篡改);② 收款人+金额的 ABI 参数必须内嵌在 raw_data(防换收款人/改额);③ USDT 合约
|
||||
必须内嵌(防换币);④ 由助记词派生的地址必须等于 owner(防错钥匙)。任一不符即拒签。
|
||||
|
||||
**gas**:TRC20 转账要 energy,收款地址身上没 TRX——归集前先给这些地址垫少量 TRX(gas 钱包),
|
||||
或用能量租赁。(垫 gas 的辅助后续加;当前 `build` 已设 `--fee-limit`。)
|
||||
|
||||
> ⚠️ 联网上链部分(`build`/余额/`broadcast`)只在真链(Phase E)验证;纯 crypto
|
||||
> (地址解码/ABI/txid/签名可恢复)已单测。首次务必**小额**实跑一遍再放量。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
- `wallet`:派生一致性(xpub 路径 == 私钥路径)+ 金标准向量(需 A.4 核对)。
|
||||
- `store`/`pay`/`watcher`/`httpapi`:建单/派生地址/侦测到账/幂等/超时/HTTP。
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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))
|
||||
|
||||
xpub := os.Getenv("PAY_ACCOUNT_XPUB")
|
||||
if xpub == "" {
|
||||
log.Error("PAY_ACCOUNT_XPUB is required (watch-only account xpub, m/44'/195'/0')")
|
||||
os.Exit(1)
|
||||
}
|
||||
dbPath := env("PAY_DB", "pay.db")
|
||||
addr := env("PAY_ADDR", ":8090")
|
||||
pollSec, _ := strconv.Atoi(env("PAY_POLL_SECONDS", "20"))
|
||||
if pollSec <= 0 {
|
||||
pollSec = 20
|
||||
}
|
||||
|
||||
st, err := store.Open("file:" + dbPath + "?_txlock=immediate")
|
||||
if err != nil {
|
||||
log.Error("open store", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = st.Close() }()
|
||||
|
||||
svc := pay.New(st, pay.Config{AccountXpub: xpub, OrderTTL: 15 * time.Minute})
|
||||
fetcher := tron.NewClient(env("TRONGRID_BASE", ""), env("USDT_CONTRACT", ""), os.Getenv("TRONGRID_API_KEY"))
|
||||
w := watcher.New(st, fetcher, log)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go w.Loop(ctx, time.Duration(pollSec)*time.Second)
|
||||
|
||||
srv := &http.Server{Addr: addr, Handler: httpapi.New(svc), ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() {
|
||||
log.Info("pangolin-pay listening", "addr", addr, "poll_seconds", pollSec)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Error("http server", "err", err)
|
||||
stop()
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
sc, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(sc)
|
||||
log.Info("pangolin-pay stopped")
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// Command sweep is the offline-signed USDT 归集 tool (Phase D). It splits the
|
||||
// work across the air gap so the mnemonic never touches an online machine:
|
||||
//
|
||||
// sweep plan (online) list derived receiving addresses that hold USDT
|
||||
// sweep build (online) build unsigned transfers -> unsigned.json (no key)
|
||||
// sweep sign (OFFLINE) verify + sign with the mnemonic -> signed.json
|
||||
// sweep broadcast (online) submit signed.json to the chain
|
||||
//
|
||||
// The mnemonic is read from env PAY_SWEEP_MNEMONIC (set on the air-gapped box
|
||||
// from Bitwarden), never a CLI arg and never on the hot service. `sign`
|
||||
// independently re-verifies every transaction (recomputes the txid from
|
||||
// raw_data, checks the recipient+amount+contract are embedded, and checks the
|
||||
// derived key matches the owner) so a compromised online builder cannot trick it
|
||||
// into signing a payment to someone else.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/tron"
|
||||
"github.com/wangjia/pangolin/pay/internal/wallet"
|
||||
)
|
||||
|
||||
type item struct {
|
||||
Index uint32 `json:"index"`
|
||||
Owner string `json:"owner"`
|
||||
Cold string `json:"cold"`
|
||||
Amount int64 `json:"amount"` // micro-USDT
|
||||
Unsigned tron.UnsignedTx `json:"unsigned"`
|
||||
Signed *tron.SignedTx `json:"signed,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fail("usage: sweep <plan|build|sign|broadcast> [flags]")
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "plan":
|
||||
cmdPlan(os.Args[2:])
|
||||
case "build":
|
||||
cmdBuild(os.Args[2:])
|
||||
case "sign":
|
||||
cmdSign(os.Args[2:])
|
||||
case "broadcast":
|
||||
cmdBroadcast(os.Args[2:])
|
||||
default:
|
||||
fail("unknown subcommand %q (plan|build|sign|broadcast)", os.Args[1])
|
||||
}
|
||||
}
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func usdtContract() string { return env("USDT_CONTRACT", tron.USDTContractMainnet) }
|
||||
|
||||
func newClient() *tron.Client {
|
||||
return tron.NewClient(env("TRONGRID_BASE", ""), usdtContract(), os.Getenv("TRONGRID_API_KEY"))
|
||||
}
|
||||
|
||||
// cmdPlan (online): scan indices 0..max, print those with a USDT balance.
|
||||
func cmdPlan(args []string) {
|
||||
fs := flag.NewFlagSet("plan", flag.ExitOnError)
|
||||
max := fs.Uint("max", 50, "highest address index to scan")
|
||||
_ = fs.Parse(args)
|
||||
xpub := mustEnv("PAY_ACCOUNT_XPUB")
|
||||
c := newClient()
|
||||
ctx := context.Background()
|
||||
var total int64
|
||||
for i := uint32(0); i <= uint32(*max); i++ {
|
||||
addr, err := wallet.AddressFromAccountXpub(xpub, 0, i)
|
||||
if err != nil {
|
||||
fail("derive %d: %v", i, err)
|
||||
}
|
||||
bal, err := c.TRC20Balance(ctx, addr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warn: balance %d %s: %v\n", i, addr, err)
|
||||
continue
|
||||
}
|
||||
if bal > 0 {
|
||||
total += bal
|
||||
fmt.Printf("index=%d\taddr=%s\tusdt=%s\n", i, addr, usdt(bal))
|
||||
}
|
||||
}
|
||||
fmt.Printf("TOTAL: %s USDT\n", usdt(total))
|
||||
}
|
||||
|
||||
// cmdBuild (online): build unsigned transfers of each address's full balance to
|
||||
// the cold wallet. No private key used.
|
||||
func cmdBuild(args []string) {
|
||||
fs := flag.NewFlagSet("build", flag.ExitOnError)
|
||||
cold := fs.String("cold", "", "cold wallet destination address (T...)")
|
||||
max := fs.Uint("max", 50, "highest address index to scan")
|
||||
feeLimit := fs.Int64("fee-limit", 30_000_000, "fee limit in sun (1e-6 TRX)")
|
||||
_ = fs.Parse(args)
|
||||
if *cold == "" {
|
||||
fail("--cold is required")
|
||||
}
|
||||
if _, err := wallet.DecodeTronAddress(*cold); err != nil {
|
||||
fail("bad --cold: %v", err)
|
||||
}
|
||||
xpub := mustEnv("PAY_ACCOUNT_XPUB")
|
||||
c := newClient()
|
||||
ctx := context.Background()
|
||||
var out []item
|
||||
for i := uint32(0); i <= uint32(*max); i++ {
|
||||
owner, err := wallet.AddressFromAccountXpub(xpub, 0, i)
|
||||
if err != nil {
|
||||
fail("derive %d: %v", i, err)
|
||||
}
|
||||
bal, err := c.TRC20Balance(ctx, owner)
|
||||
if err != nil || bal <= 0 {
|
||||
continue
|
||||
}
|
||||
ut, err := c.BuildTransfer(ctx, owner, *cold, bal, *feeLimit)
|
||||
if err != nil {
|
||||
fail("build %d %s: %v", i, owner, err)
|
||||
}
|
||||
out = append(out, item{Index: i, Owner: owner, Cold: *cold, Amount: bal, Unsigned: *ut})
|
||||
fmt.Fprintf(os.Stderr, "built index=%d %s -> %s %s USDT\n", i, owner, *cold, usdt(bal))
|
||||
}
|
||||
emit(out)
|
||||
}
|
||||
|
||||
// cmdSign (OFFLINE): verify each tx independently, then sign with the mnemonic.
|
||||
func cmdSign(args []string) {
|
||||
fs := flag.NewFlagSet("sign", flag.ExitOnError)
|
||||
cold := fs.String("cold", "", "expected cold destination (guards against tampering)")
|
||||
_ = fs.Parse(args)
|
||||
mnemonic := mustEnv("PAY_SWEEP_MNEMONIC") // set on the air-gapped box from Bitwarden
|
||||
items := read()
|
||||
contractBodyHex, err := wallet.TronAddressBodyHex(usdtContract())
|
||||
if err != nil {
|
||||
fail("usdt contract: %v", err)
|
||||
}
|
||||
for i := range items {
|
||||
it := &items[i]
|
||||
|
||||
if *cold != "" && it.Cold != *cold {
|
||||
fail("index %d: cold %s != expected %s", it.Index, it.Cold, *cold)
|
||||
}
|
||||
// 1) raw_data integrity: recompute txid, must equal the claimed one.
|
||||
txid, err := tron.TxID(it.Unsigned.RawDataHex)
|
||||
if err != nil {
|
||||
fail("index %d: txid: %v", it.Index, err)
|
||||
}
|
||||
if !strings.EqualFold(hex.EncodeToString(txid), it.Unsigned.TxID) {
|
||||
fail("index %d: txid mismatch — raw_data tampered", it.Index)
|
||||
}
|
||||
// 2) recipient + amount: the exact ABI param must be embedded in raw_data.
|
||||
wantParam, err := tron.ABIEncodeTransferParams(it.Cold, it.Amount)
|
||||
if err != nil {
|
||||
fail("index %d: abi: %v", it.Index, err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(it.Unsigned.RawDataHex), strings.ToLower(wantParam)) {
|
||||
fail("index %d: recipient/amount not found in raw_data — refusing to sign", it.Index)
|
||||
}
|
||||
// 3) contract: the USDT contract body must be in raw_data (right token).
|
||||
if !strings.Contains(strings.ToLower(it.Unsigned.RawDataHex), strings.ToLower(contractBodyHex)) {
|
||||
fail("index %d: USDT contract not found in raw_data — refusing to sign", it.Index)
|
||||
}
|
||||
// 4) key: the derived address for this index must equal the owner.
|
||||
addr, err := wallet.AddressFromMnemonic(mnemonic, "", 0, 0, it.Index)
|
||||
if err != nil {
|
||||
fail("index %d: derive addr: %v", it.Index, err)
|
||||
}
|
||||
if addr != it.Owner {
|
||||
fail("index %d: derived %s != owner %s — wrong mnemonic/index", it.Index, addr, it.Owner)
|
||||
}
|
||||
|
||||
priv, err := wallet.PrivKeyHexFromMnemonic(mnemonic, "", 0, 0, it.Index)
|
||||
if err != nil {
|
||||
fail("index %d: privkey: %v", it.Index, err)
|
||||
}
|
||||
sig, err := tron.SignRawData(it.Unsigned.RawDataHex, priv)
|
||||
if err != nil {
|
||||
fail("index %d: sign: %v", it.Index, err)
|
||||
}
|
||||
it.Signed = &tron.SignedTx{
|
||||
TxID: it.Unsigned.TxID,
|
||||
RawData: it.Unsigned.RawData,
|
||||
RawDataHex: it.Unsigned.RawDataHex,
|
||||
Visible: it.Unsigned.Visible,
|
||||
Signature: []string{sig},
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "signed index=%d %s -> %s %s USDT\n", it.Index, it.Owner, it.Cold, usdt(it.Amount))
|
||||
}
|
||||
emit(items)
|
||||
}
|
||||
|
||||
// cmdBroadcast (online): submit each signed tx.
|
||||
func cmdBroadcast(args []string) {
|
||||
_ = flag.NewFlagSet("broadcast", flag.ExitOnError).Parse(args)
|
||||
items := read()
|
||||
c := newClient()
|
||||
ctx := context.Background()
|
||||
for i := range items {
|
||||
it := &items[i]
|
||||
if it.Signed == nil {
|
||||
fail("index %d: not signed", it.Index)
|
||||
}
|
||||
txid, err := c.Broadcast(ctx, it.Signed)
|
||||
if err != nil {
|
||||
fail("index %d: broadcast: %v", it.Index, err)
|
||||
}
|
||||
fmt.Printf("broadcast index=%d %s USDT -> tx %s\n", it.Index, usdt(it.Amount), txid)
|
||||
time.Sleep(200 * time.Millisecond) // be gentle with the endpoint
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func usdt(micro int64) string {
|
||||
return strconv.FormatFloat(float64(micro)/1e6, 'f', 6, 64)
|
||||
}
|
||||
|
||||
func mustEnv(k string) string {
|
||||
v := os.Getenv(k)
|
||||
if v == "" {
|
||||
fail("env %s is required", k)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func read() []item {
|
||||
var items []item
|
||||
if err := json.NewDecoder(os.Stdin).Decode(&items); err != nil {
|
||||
fail("read json from stdin: %v", err)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func emit(items []item) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(items); err != nil {
|
||||
fail("write json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fail(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, "sweep: "+format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
module github.com/wangjia/pangolin/pay
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/btcsuite/btcd v0.24.2
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.5
|
||||
github.com/btcsuite/btcd/btcutil v1.2.0
|
||||
github.com/tyler-smith/go-bip39 v1.1.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
|
||||
github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.5 h1:dpAlnAwmT1yIBm3exhT1/8iUSD98RDJM5vqJVQDQLiU=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.5/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ=
|
||||
github.com/btcsuite/btcd/btcutil v1.2.0 h1:p3+S2g3Q+7G5NOh4Ji+2UrBOrg5Z0Q4ykzShWG1Dhgs=
|
||||
github.com/btcsuite/btcd/btcutil v1.2.0/go.mod h1:/Taflm113pYjUpbWKKQEfa6XOtI/+WS8awxeMZpY75k=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee h1:FPP9HDkBbPyniu+u7FHZg+kKFX1WW0gxOGteJ0h3AJk=
|
||||
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee/go.mod h1:N6sz6HwJAenJ6d+/xmSl0ikfV05ZrVGmjt1ryy/WOtE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
|
||||
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package httpapi exposes the order endpoints a storefront (e.g. 独角数卡) calls:
|
||||
// create a payment (get a receiving address) and poll its status.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/pay"
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
)
|
||||
|
||||
type Handler struct{ svc *pay.Service }
|
||||
|
||||
// New wires the routes (Go 1.22 method+wildcard patterns).
|
||||
func New(svc *pay.Service) http.Handler {
|
||||
h := &Handler{svc: svc}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /order", h.createOrder)
|
||||
mux.HandleFunc("GET /order/{orderNo}", h.getOrder)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
type createReq struct {
|
||||
SKU string `json:"sku"`
|
||||
Amount int64 `json:"amount"` // micro-USDT (1e-6)
|
||||
}
|
||||
|
||||
type orderResp struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
Address string `json:"address"`
|
||||
ExpectAmount int64 `json:"expect_amount"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
TxID string `json:"tx_id,omitempty"`
|
||||
}
|
||||
|
||||
func toResp(o *store.Order) orderResp {
|
||||
return orderResp{
|
||||
OrderNo: o.OrderNo,
|
||||
Address: o.Address,
|
||||
ExpectAmount: o.ExpectAmount,
|
||||
Status: string(o.Status),
|
||||
ExpiresAt: o.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
TxID: o.TxID,
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func (h *Handler) createOrder(w http.ResponseWriter, r *http.Request) {
|
||||
var req createReq
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
o, err := h.svc.CreateOrder(r.Context(), req.SKU, req.Amount)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toResp(o))
|
||||
}
|
||||
|
||||
func (h *Handler) getOrder(w http.ResponseWriter, r *http.Request) {
|
||||
o, err := h.svc.GetOrder(r.Context(), r.PathValue("orderNo"))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toResp(o))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/pay"
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
)
|
||||
|
||||
const testXpub = "xpub6D1AabNHCupeiLM65ZR9UStMhJ1vCpyV4XbZdyhMZBiJXALQtmn9p42VTQckoHVn8WNqS7dqnJokZHAHcHGoaQgmv8D45oNUKx6DZMNZBCd"
|
||||
|
||||
func TestCreateAndGetOrder(t *testing.T) {
|
||||
st, _ := store.Open(":memory:")
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
srv := httptest.NewServer(New(pay.New(st, pay.Config{AccountXpub: testXpub})))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"sku": "pro-year", "amount": 5_000000})
|
||||
resp, err := http.Post(srv.URL+"/order", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create status %d", resp.StatusCode)
|
||||
}
|
||||
var created orderResp
|
||||
_ = json.NewDecoder(resp.Body).Decode(&created)
|
||||
_ = resp.Body.Close()
|
||||
if created.Address != "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH" || created.Status != "pending" {
|
||||
t.Fatalf("create resp: %+v", created)
|
||||
}
|
||||
|
||||
r2, _ := http.Get(srv.URL + "/order/" + created.OrderNo)
|
||||
if r2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get status %d", r2.StatusCode)
|
||||
}
|
||||
var got orderResp
|
||||
_ = json.NewDecoder(r2.Body).Decode(&got)
|
||||
_ = r2.Body.Close()
|
||||
if got.OrderNo != created.OrderNo || got.Address != created.Address {
|
||||
t.Fatalf("get mismatch: %+v vs %+v", got, created)
|
||||
}
|
||||
|
||||
r3, _ := http.Get(srv.URL + "/order/NOPE")
|
||||
if r3.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", r3.StatusCode)
|
||||
}
|
||||
_ = r3.Body.Close()
|
||||
|
||||
r4, _ := http.Post(srv.URL+"/order", "application/json", bytes.NewReader([]byte(`{"sku":"x","amount":0}`)))
|
||||
if r4.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 for bad amount, got %d", r4.StatusCode)
|
||||
}
|
||||
_ = r4.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package pay is the order service: create a payment (derive a fresh receiving
|
||||
// address, record a pending order) and look one up.
|
||||
package pay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
"github.com/wangjia/pangolin/pay/internal/wallet"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AccountXpub string // watch-only account xpub (m/44'/195'/0')
|
||||
OrderTTL time.Duration // how long a pending order stays payable
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
cfg Config
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(st *store.Store, cfg Config) *Service {
|
||||
if cfg.OrderTTL <= 0 {
|
||||
cfg.OrderTTL = 15 * time.Minute
|
||||
}
|
||||
return &Service{st: st, cfg: cfg, now: time.Now}
|
||||
}
|
||||
|
||||
// CreateOrder assigns a fresh HD receiving address and records a pending order.
|
||||
// amount is in micro-USDT (1e-6).
|
||||
func (s *Service) CreateOrder(ctx context.Context, sku string, amount int64) (*store.Order, error) {
|
||||
if amount <= 0 {
|
||||
return nil, fmt.Errorf("pay: amount must be positive")
|
||||
}
|
||||
if sku == "" {
|
||||
return nil, fmt.Errorf("pay: sku required")
|
||||
}
|
||||
idx, err := s.st.NextAddrIndex(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pay: next addr index: %w", err)
|
||||
}
|
||||
addr, err := wallet.AddressFromAccountXpub(s.cfg.AccountXpub, 0, idx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pay: derive address: %w", err)
|
||||
}
|
||||
now := s.now()
|
||||
o := &store.Order{
|
||||
OrderNo: newOrderNo(now),
|
||||
SKU: sku,
|
||||
ExpectAmount: amount,
|
||||
AddrIndex: idx,
|
||||
Address: addr,
|
||||
Status: store.StatusPending,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(s.cfg.OrderTTL),
|
||||
}
|
||||
if err := s.st.CreateOrder(ctx, o); err != nil {
|
||||
return nil, fmt.Errorf("pay: create order: %w", err)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetOrder(ctx context.Context, orderNo string) (*store.Order, error) {
|
||||
return s.st.GetOrder(ctx, orderNo)
|
||||
}
|
||||
|
||||
func newOrderNo(t time.Time) string {
|
||||
var b [6]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return fmt.Sprintf("PAY%s%x", t.UTC().Format("20060102150405"), b)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package pay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
)
|
||||
|
||||
// Same golden test-mnemonic account xpub as the wallet package. First two
|
||||
// receiving addresses (index 0,1) are locked so we prove CreateOrder assigns the
|
||||
// right HD address and advances the cursor.
|
||||
const testXpub = "xpub6D1AabNHCupeiLM65ZR9UStMhJ1vCpyV4XbZdyhMZBiJXALQtmn9p42VTQckoHVn8WNqS7dqnJokZHAHcHGoaQgmv8D45oNUKx6DZMNZBCd"
|
||||
|
||||
func TestCreateOrderDerivesSequentialAddresses(t *testing.T) {
|
||||
st, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
svc := New(st, Config{AccountXpub: testXpub})
|
||||
ctx := context.Background()
|
||||
|
||||
o0, err := svc.CreateOrder(ctx, "pro-year", 5_000000)
|
||||
if err != nil {
|
||||
t.Fatalf("order0: %v", err)
|
||||
}
|
||||
if o0.AddrIndex != 0 || o0.Address != "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH" {
|
||||
t.Fatalf("order0 addr: idx=%d addr=%s", o0.AddrIndex, o0.Address)
|
||||
}
|
||||
if o0.Status != store.StatusPending || o0.ExpiresAt.Before(o0.CreatedAt) {
|
||||
t.Fatalf("order0 state: %+v", o0)
|
||||
}
|
||||
|
||||
o1, err := svc.CreateOrder(ctx, "pro-month", 500000)
|
||||
if err != nil {
|
||||
t.Fatalf("order1: %v", err)
|
||||
}
|
||||
if o1.AddrIndex != 1 || o1.Address != "TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK" {
|
||||
t.Fatalf("order1 addr: idx=%d addr=%s", o1.AddrIndex, o1.Address)
|
||||
}
|
||||
if o1.Address == o0.Address {
|
||||
t.Fatal("addresses must not repeat across orders")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrderRejectsBadInput(t *testing.T) {
|
||||
st, _ := store.Open(":memory:")
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
svc := New(st, Config{AccountXpub: testXpub})
|
||||
ctx := context.Background()
|
||||
if _, err := svc.CreateOrder(ctx, "x", 0); err == nil {
|
||||
t.Fatal("expected error for non-positive amount")
|
||||
}
|
||||
if _, err := svc.CreateOrder(ctx, "", 100); err == nil {
|
||||
t.Fatal("expected error for empty sku")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package store persists pay orders + the HD address-derivation cursor in
|
||||
// SQLite (pure-Go modernc driver, no CGO — same choice as the control plane).
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusPaid Status = "paid"
|
||||
StatusExpired Status = "expired"
|
||||
)
|
||||
|
||||
// Order is one payment request. Amounts are in micro-USDT (1e-6), matching the
|
||||
// raw integer value of a TRC20 USDT transfer (USDT has 6 decimals).
|
||||
type Order struct {
|
||||
OrderNo string
|
||||
SKU string
|
||||
ExpectAmount int64
|
||||
AddrIndex uint32
|
||||
Address string
|
||||
Status Status
|
||||
TxID string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var ErrNotFound = errors.New("store: order not found")
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
func Open(dsn string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1) // SQLite: serialize writers, avoid "database is locked"
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS pay_orders(
|
||||
order_no TEXT PRIMARY KEY,
|
||||
sku TEXT NOT NULL,
|
||||
expect_amount INTEGER NOT NULL,
|
||||
addr_index INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
tx_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_orders_status ON pay_orders(status)`,
|
||||
`CREATE TABLE IF NOT EXISTS addr_cursor(id INTEGER PRIMARY KEY CHECK(id=1), next_index INTEGER NOT NULL)`,
|
||||
`INSERT OR IGNORE INTO addr_cursor(id, next_index) VALUES(1, 0)`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NextAddrIndex atomically returns the current HD index and advances the cursor.
|
||||
// Addresses are never reused (avoids an old payment landing on a recycled slot).
|
||||
func (s *Store) NextAddrIndex(ctx context.Context) (uint32, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var idx uint32
|
||||
if err := tx.QueryRowContext(ctx, `SELECT next_index FROM addr_cursor WHERE id=1`).Scan(&idx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE addr_cursor SET next_index=? WHERE id=1`, idx+1); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateOrder(ctx context.Context, o *Order) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO pay_orders(order_no,sku,expect_amount,addr_index,address,status,created_at,expires_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`,
|
||||
o.OrderNo, o.SKU, o.ExpectAmount, o.AddrIndex, o.Address, o.Status, o.CreatedAt.Unix(), o.ExpiresAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
const cols = `order_no,sku,expect_amount,addr_index,address,status,tx_id,created_at,expires_at`
|
||||
|
||||
func scanOrder(sc interface{ Scan(...any) error }) (*Order, error) {
|
||||
o := &Order{}
|
||||
var created, expires int64
|
||||
if err := sc.Scan(&o.OrderNo, &o.SKU, &o.ExpectAmount, &o.AddrIndex, &o.Address, &o.Status, &o.TxID, &created, &expires); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.CreatedAt = time.Unix(created, 0)
|
||||
o.ExpiresAt = time.Unix(expires, 0)
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOrder(ctx context.Context, orderNo string) (*Order, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+cols+` FROM pay_orders WHERE order_no=?`, orderNo)
|
||||
o, err := scanOrder(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return o, err
|
||||
}
|
||||
|
||||
func (s *Store) ListPending(ctx context.Context) ([]*Order, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+cols+` FROM pay_orders WHERE status=?`, StatusPending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []*Order
|
||||
for rows.Next() {
|
||||
o, err := scanOrder(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkPaid transitions pending->paid, idempotently (only affects a still-pending
|
||||
// row). Returns true if this call was the one that flipped it.
|
||||
func (s *Store) MarkPaid(ctx context.Context, orderNo, txID string) (bool, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE pay_orders SET status=?, tx_id=? WHERE order_no=? AND status=?`,
|
||||
StatusPaid, txID, orderNo, StatusPending)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// MarkExpired flips pending->expired for orders past their deadline.
|
||||
func (s *Store) MarkExpired(ctx context.Context, now time.Time) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE pay_orders SET status=? WHERE status=? AND expires_at < ?`,
|
||||
StatusExpired, StatusPending, now.Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func openMem(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestNextAddrIndexMonotonic(t *testing.T) {
|
||||
s := openMem(t)
|
||||
ctx := context.Background()
|
||||
for want := uint32(0); want < 5; want++ {
|
||||
got, err := s.NextAddrIndex(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("next: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("index got %d want %d", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderRoundtripAndMarkPaidIdempotent(t *testing.T) {
|
||||
s := openMem(t)
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
o := &Order{
|
||||
OrderNo: "PAY1", SKU: "pro-year", ExpectAmount: 5_000000, AddrIndex: 0,
|
||||
Address: "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", Status: StatusPending,
|
||||
CreatedAt: now, ExpiresAt: now.Add(15 * time.Minute),
|
||||
}
|
||||
if err := s.CreateOrder(ctx, o); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
got, err := s.GetOrder(ctx, "PAY1")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.SKU != "pro-year" || got.ExpectAmount != 5_000000 || got.Status != StatusPending {
|
||||
t.Fatalf("roundtrip mismatch: %+v", got)
|
||||
}
|
||||
|
||||
ok, err := s.MarkPaid(ctx, "PAY1", "tx-abc")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("first MarkPaid ok=%v err=%v (want true,nil)", ok, err)
|
||||
}
|
||||
ok2, err := s.MarkPaid(ctx, "PAY1", "tx-dup")
|
||||
if err != nil || ok2 {
|
||||
t.Fatalf("second MarkPaid ok=%v err=%v (want false,nil — idempotent)", ok2, err)
|
||||
}
|
||||
got, _ = s.GetOrder(ctx, "PAY1")
|
||||
if got.Status != StatusPaid || got.TxID != "tx-abc" {
|
||||
t.Fatalf("after paid: status=%s tx=%s (want paid,tx-abc)", got.Status, got.TxID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkExpired(t *testing.T) {
|
||||
s := openMem(t)
|
||||
ctx := context.Background()
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
past := &Order{OrderNo: "old", SKU: "x", ExpectAmount: 1, Address: "T1", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(1 * time.Minute)}
|
||||
future := &Order{OrderNo: "new", SKU: "x", ExpectAmount: 1, Address: "T2", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(1 * time.Hour)}
|
||||
_ = s.CreateOrder(ctx, past)
|
||||
_ = s.CreateOrder(ctx, future)
|
||||
|
||||
n, err := s.MarkExpired(ctx, base.Add(10*time.Minute))
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("MarkExpired n=%d err=%v (want 1)", n, err)
|
||||
}
|
||||
oldO, _ := s.GetOrder(ctx, "old")
|
||||
newO, _ := s.GetOrder(ctx, "new")
|
||||
if oldO.Status != StatusExpired {
|
||||
t.Fatalf("old should be expired, got %s", oldO.Status)
|
||||
}
|
||||
if newO.Status != StatusPending {
|
||||
t.Fatalf("new should still be pending, got %s", newO.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/wallet"
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// TransferSelector is the TRC20 transfer(address,uint256) function selector
|
||||
// string that TronGrid's triggersmartcontract expects.
|
||||
const TransferSelector = "transfer(address,uint256)"
|
||||
|
||||
// ABIEncodeTransferParams builds the 64-byte ABI parameter for
|
||||
// transfer(address,uint256): the recipient (20-byte body, left-padded to 32) and
|
||||
// the amount (uint256, left-padded to 32). Returns hex (no 0x, no 4-byte
|
||||
// selector — TronGrid derives the selector from TransferSelector).
|
||||
func ABIEncodeTransferParams(toAddr string, amount int64) (string, error) {
|
||||
if amount <= 0 {
|
||||
return "", fmt.Errorf("tron: transfer amount must be positive")
|
||||
}
|
||||
payload, err := wallet.DecodeTronAddress(toAddr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := make([]byte, 64)
|
||||
copy(out[12:32], payload[1:]) // 20-byte body, right-aligned in first word
|
||||
new(big.Int).SetInt64(amount).FillBytes(out[32:64])
|
||||
return hex.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
// keccakAddressBody derives the 20-byte address body from a 65-byte uncompressed
|
||||
// secp256k1 public key: keccak256(X||Y)[12:].
|
||||
func keccakAddressBody(uncompressed []byte) []byte {
|
||||
h := sha3.NewLegacyKeccak256()
|
||||
h.Write(uncompressed[1:])
|
||||
sum := h.Sum(nil)
|
||||
return sum[12:]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Package tron reads confirmed incoming TRC20 (USDT) transfers from TronGrid.
|
||||
// Only reads — the watcher never signs or moves funds (that's offline sweeping).
|
||||
package tron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// USDTContractMainnet is the TRON mainnet USDT (TRC20) contract. 6 decimals.
|
||||
// ⚠️ Verify before relying on it in production (Phase-level constant check).
|
||||
const USDTContractMainnet = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||
|
||||
// Transfer is one confirmed incoming TRC20 transfer to a watched address.
|
||||
// Value is the raw integer amount (micro-USDT, since USDT has 6 decimals).
|
||||
type Transfer struct {
|
||||
TxID string
|
||||
To string
|
||||
Value int64
|
||||
}
|
||||
|
||||
// Fetcher returns confirmed incoming USDT transfers to a given address.
|
||||
type Fetcher interface {
|
||||
IncomingTransfers(ctx context.Context, address string) ([]Transfer, error)
|
||||
}
|
||||
|
||||
// Client talks to the TronGrid HTTP API.
|
||||
type Client struct {
|
||||
base string
|
||||
usdtContract string
|
||||
apiKey string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func NewClient(base, usdtContract, apiKey string) *Client {
|
||||
if base == "" {
|
||||
base = "https://api.trongrid.io"
|
||||
}
|
||||
if usdtContract == "" {
|
||||
usdtContract = USDTContractMainnet
|
||||
}
|
||||
return &Client{base: base, usdtContract: usdtContract, apiKey: apiKey, hc: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
func (c *Client) IncomingTransfers(ctx context.Context, address string) ([]Transfer, error) {
|
||||
u := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20?only_confirmed=true&contract_address=%s&limit=50",
|
||||
c.base, url.PathEscape(address), url.QueryEscape(c.usdtContract))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("tron: trongrid status %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data []struct {
|
||||
TransactionID string `json:"transaction_id"`
|
||||
To string `json:"to"`
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("tron: decode: %w", err)
|
||||
}
|
||||
out := make([]Transfer, 0, len(body.Data))
|
||||
for _, d := range body.Data {
|
||||
if d.To != address || d.Type != "Transfer" {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseInt(d.Value, 10, 64)
|
||||
if err != nil {
|
||||
continue // skip malformed value rather than fail the whole batch
|
||||
}
|
||||
out = append(out, Transfer{TxID: d.TransactionID, To: d.To, Value: v})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcec/v2/ecdsa"
|
||||
)
|
||||
|
||||
// TxID computes the TRON transaction id: sha256 of the raw_data bytes. The
|
||||
// signature is made over this hash. Recomputing it offline from raw_data_hex
|
||||
// (rather than trusting a txID handed over by the online builder) is what makes
|
||||
// air-gapped signing safe — a tampered raw_data yields a different id.
|
||||
func TxID(rawDataHex string) ([]byte, error) {
|
||||
raw, err := hex.DecodeString(rawDataHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tron: raw_data hex: %w", err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("tron: empty raw_data")
|
||||
}
|
||||
h := sha256.Sum256(raw)
|
||||
return h[:], nil
|
||||
}
|
||||
|
||||
// SignRawData signs raw_data with a hex private key and returns the 65-byte TRON
|
||||
// signature hex: R(32) || S(32) || recid(1, value 0/1). OFFLINE ONLY.
|
||||
func SignRawData(rawDataHex, privHex string) (string, error) {
|
||||
txid, err := TxID(rawDataHex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pb, err := hex.DecodeString(privHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tron: privkey hex: %w", err)
|
||||
}
|
||||
priv, _ := btcec.PrivKeyFromBytes(pb)
|
||||
|
||||
// SignCompact returns 65 bytes: [header || R || S], header = 27+recid for an
|
||||
// uncompressed key. TRON wants R || S || recid, so rearrange.
|
||||
compact := ecdsa.SignCompact(priv, txid, false)
|
||||
if len(compact) != 65 {
|
||||
return "", fmt.Errorf("tron: unexpected compact signature length %d", len(compact))
|
||||
}
|
||||
recid := compact[0] - 27
|
||||
sig := make([]byte, 0, 65)
|
||||
sig = append(sig, compact[1:65]...) // R || S
|
||||
sig = append(sig, recid) // recovery id 0/1
|
||||
return hex.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// RecoverAddressBody recovers the signer's 20-byte address body from a raw_data
|
||||
// hex + TRON signature hex — used by tests (and could verify a signature).
|
||||
func RecoverAddressBody(rawDataHex, sigHex string) ([]byte, error) {
|
||||
txid, err := TxID(rawDataHex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sig, err := hex.DecodeString(sigHex)
|
||||
if err != nil || len(sig) != 65 {
|
||||
return nil, fmt.Errorf("tron: signature must be 65 bytes hex")
|
||||
}
|
||||
// Rebuild btcec compact layout: [header=27+recid || R || S].
|
||||
compact := make([]byte, 65)
|
||||
compact[0] = 27 + sig[64]
|
||||
copy(compact[1:], sig[:64])
|
||||
pub, _, err := ecdsa.RecoverCompact(compact, txid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tron: recover: %w", err)
|
||||
}
|
||||
return keccakAddressBody(pub.SerializeUncompressed()), nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/wallet"
|
||||
)
|
||||
|
||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// addr index 0 of the test mnemonic (see wallet golden vector).
|
||||
const testAddr0 = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH"
|
||||
|
||||
// TestSignRecoverRoundtrip proves the signing path is correct: signing raw_data
|
||||
// with address 0's private key yields a signature that recovers to address 0.
|
||||
// This is the crux of the money-moving path — if it holds, TRON will accept the
|
||||
// signature as coming from the owner.
|
||||
func TestSignRecoverRoundtrip(t *testing.T) {
|
||||
priv, err := wallet.PrivKeyHexFromMnemonic(testMnemonic, "", 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("privkey: %v", err)
|
||||
}
|
||||
rawHex := "0a0212340a0212341234567890abcdef" // arbitrary non-empty raw_data
|
||||
|
||||
sig, err := SignRawData(rawHex, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
if len(sig) != 130 { // 65 bytes == 130 hex chars
|
||||
t.Fatalf("signature hex len %d, want 130", len(sig))
|
||||
}
|
||||
|
||||
body, err := RecoverAddressBody(rawHex, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("recover: %v", err)
|
||||
}
|
||||
wantBody, _ := wallet.TronAddressBodyHex(testAddr0)
|
||||
if hex.EncodeToString(body) != wantBody {
|
||||
t.Fatalf("recovered body %x != address 0 body %s", body, wantBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABIEncodeTransferParams(t *testing.T) {
|
||||
p, err := ABIEncodeTransferParams(testAddr0, 5_000000)
|
||||
if err != nil {
|
||||
t.Fatalf("abi: %v", err)
|
||||
}
|
||||
if len(p) != 128 { // 64 bytes == 128 hex chars
|
||||
t.Fatalf("param hex len %d, want 128", len(p))
|
||||
}
|
||||
// bytes[12:32] must equal the 20-byte address body.
|
||||
wantBody, _ := wallet.TronAddressBodyHex(testAddr0)
|
||||
if p[24:64] != wantBody {
|
||||
t.Fatalf("recipient word %s != body %s", p[24:64], wantBody)
|
||||
}
|
||||
// bytes[0:12] must be zero padding.
|
||||
if p[0:24] != "000000000000000000000000" {
|
||||
t.Fatalf("recipient not left-padded: %s", p[0:24])
|
||||
}
|
||||
// amount word must decode to 5000000.
|
||||
amt, ok := new(big.Int).SetString(p[64:128], 16)
|
||||
if !ok || amt.Int64() != 5_000000 {
|
||||
t.Fatalf("amount word decodes to %v, want 5000000", amt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABIEncodeRejectsBad(t *testing.T) {
|
||||
if _, err := ABIEncodeTransferParams(testAddr0, 0); err == nil {
|
||||
t.Fatal("expected error on zero amount")
|
||||
}
|
||||
if _, err := ABIEncodeTransferParams("garbage", 1); err == nil {
|
||||
t.Fatal("expected error on bad address")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/wallet"
|
||||
)
|
||||
|
||||
// ⚠️ The online chain ops below (BuildTransfer / TRC20Balance / Broadcast) can
|
||||
// only be fully validated against a live TronGrid + real funds (Phase E). The
|
||||
// pure crypto (sign.go / abi.go / address.go) is unit-tested; these are not.
|
||||
|
||||
// UnsignedTx is the transaction object TronGrid returns from triggersmartcontract.
|
||||
// raw_data is kept verbatim so it round-trips unchanged into broadcast.
|
||||
type UnsignedTx struct {
|
||||
TxID string `json:"txID"`
|
||||
RawData json.RawMessage `json:"raw_data"`
|
||||
RawDataHex string `json:"raw_data_hex"`
|
||||
Visible bool `json:"visible"`
|
||||
}
|
||||
|
||||
// SignedTx is an UnsignedTx with the signature attached, ready to broadcast.
|
||||
type SignedTx struct {
|
||||
TxID string `json:"txID"`
|
||||
RawData json.RawMessage `json:"raw_data"`
|
||||
RawDataHex string `json:"raw_data_hex"`
|
||||
Visible bool `json:"visible"`
|
||||
Signature []string `json:"signature"`
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, path string, body, out any) error {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("tron: %s status %d", path, resp.StatusCode)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// BuildTransfer asks TronGrid to construct an unsigned TRC20 transfer of amount
|
||||
// (micro-USDT) from ownerAddr to toAddr. No private key involved — safe online.
|
||||
// feeLimit is in sun (1e-6 TRX); ~30 TRX (30_000_000) is a safe cap for a TRC20
|
||||
// transfer.
|
||||
func (c *Client) BuildTransfer(ctx context.Context, ownerAddr, toAddr string, amount, feeLimit int64) (*UnsignedTx, error) {
|
||||
ownerBody, err := wallet.DecodeTronAddress(ownerAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contractBody, err := wallet.DecodeTronAddress(c.usdtContract)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
param, err := ABIEncodeTransferParams(toAddr, amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody := map[string]any{
|
||||
"owner_address": hex.EncodeToString(ownerBody),
|
||||
"contract_address": hex.EncodeToString(contractBody),
|
||||
"function_selector": TransferSelector,
|
||||
"parameter": param,
|
||||
"fee_limit": feeLimit,
|
||||
"call_value": 0,
|
||||
"visible": false,
|
||||
}
|
||||
var resp struct {
|
||||
Transaction UnsignedTx `json:"transaction"`
|
||||
Result struct {
|
||||
Result bool `json:"result"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := c.postJSON(ctx, "/wallet/triggersmartcontract", reqBody, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Transaction.RawDataHex == "" {
|
||||
return nil, fmt.Errorf("tron: build transfer failed: %s %s", resp.Result.Code, decodeHexMessage(resp.Result.Message))
|
||||
}
|
||||
return &resp.Transaction, nil
|
||||
}
|
||||
|
||||
// TRC20Balance returns the address's USDT balance in micro-USDT.
|
||||
func (c *Client) TRC20Balance(ctx context.Context, addr string) (int64, error) {
|
||||
u := fmt.Sprintf("/v1/accounts/%s", addr)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+u, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("tron: account status %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data []struct {
|
||||
TRC20 []map[string]string `json:"trc20"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(body.Data) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
for _, m := range body.Data[0].TRC20 {
|
||||
if v, ok := m[c.usdtContract]; ok {
|
||||
return strconv.ParseInt(v, 10, 64)
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Broadcast submits a signed transaction. Returns the on-chain txid on success.
|
||||
func (c *Client) Broadcast(ctx context.Context, tx *SignedTx) (string, error) {
|
||||
var resp struct {
|
||||
Result bool `json:"result"`
|
||||
Txid string `json:"txid"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.postJSON(ctx, "/wallet/broadcasttransaction", tx, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !resp.Result {
|
||||
return "", fmt.Errorf("tron: broadcast rejected: %s %s", resp.Code, decodeHexMessage(resp.Message))
|
||||
}
|
||||
return resp.Txid, nil
|
||||
}
|
||||
|
||||
// decodeHexMessage best-effort decodes TronGrid's hex-encoded error messages.
|
||||
func decodeHexMessage(s string) string {
|
||||
if b, err := hex.DecodeString(s); err == nil && len(b) > 0 {
|
||||
return string(b)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/base58"
|
||||
)
|
||||
|
||||
// DecodeTronAddress decodes a base58check TRON address ("T...") to its 21-byte
|
||||
// payload (0x41 || 20-byte body), validating the checksum. The 20-byte body
|
||||
// (payload[1:]) is what TRON ABI parameters use (left-padded to 32 bytes).
|
||||
func DecodeTronAddress(addr string) ([]byte, error) {
|
||||
raw := base58.Decode(addr)
|
||||
if len(raw) != 25 { // 21 payload + 4 checksum
|
||||
return nil, fmt.Errorf("wallet: bad TRON address length %d", len(raw))
|
||||
}
|
||||
payload, sum := raw[:21], raw[21:]
|
||||
h1 := sha256.Sum256(payload)
|
||||
h2 := sha256.Sum256(h1[:])
|
||||
if !bytes.Equal(h2[:4], sum) {
|
||||
return nil, fmt.Errorf("wallet: bad TRON address checksum")
|
||||
}
|
||||
if payload[0] != tronAddrPrefix {
|
||||
return nil, fmt.Errorf("wallet: bad TRON address prefix 0x%02x", payload[0])
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// TronAddressBodyHex returns the 20-byte address body as hex (no 0x41 prefix) —
|
||||
// used to build/verify ABI-encoded transfer recipients.
|
||||
func TronAddressBodyHex(addr string) (string, error) {
|
||||
payload, err := DecodeTronAddress(addr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", payload[1:]), nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package wallet
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecodeTronAddressRoundtrip(t *testing.T) {
|
||||
// Golden addresses from the test mnemonic (see derive_test.go).
|
||||
for _, addr := range goldenAddrs {
|
||||
payload, err := DecodeTronAddress(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("decode %s: %v", addr, err)
|
||||
}
|
||||
if len(payload) != 21 || payload[0] != 0x41 {
|
||||
t.Fatalf("bad payload for %s: %x", addr, payload)
|
||||
}
|
||||
// Re-encode the payload and expect the same address back.
|
||||
if got := base58CheckEncode(payload); got != addr {
|
||||
t.Fatalf("roundtrip: %s -> %s", addr, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTronAddressRejectsBad(t *testing.T) {
|
||||
if _, err := DecodeTronAddress("TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdX"); err == nil {
|
||||
t.Fatal("expected checksum failure on tampered address")
|
||||
}
|
||||
if _, err := DecodeTronAddress("not-an-address"); err == nil {
|
||||
t.Fatal("expected failure on garbage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTronAddressBodyHex(t *testing.T) {
|
||||
body, err := TronAddressBodyHex(goldenAddrs[0])
|
||||
if err != nil {
|
||||
t.Fatalf("body hex: %v", err)
|
||||
}
|
||||
if len(body) != 40 { // 20 bytes -> 40 hex chars
|
||||
t.Fatalf("body hex len %d, want 40 (%s)", len(body), body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Package wallet derives TRON (TRC20) receiving addresses from a BIP32 account
|
||||
// extended public key (xpub) — watch-only, no private keys involved. The
|
||||
// pangolin-pay watcher uses AddressFromAccountXpub to assign a unique receiving
|
||||
// address per order (m/44'/195'/0'/0/i). Private-key material (seed.go) is for
|
||||
// OFFLINE use only (sweep signing / vector generation), never on the hot service.
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/base58"
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// tronAddrPrefix is the TRON mainnet address version byte (0x41). It is prepended
|
||||
// to the 20-byte address body before Base58Check encoding, yielding the familiar
|
||||
// "T..." addresses.
|
||||
const tronAddrPrefix = 0x41
|
||||
|
||||
// AddressFromAccountXpub derives the TRON address at m/…/<change>/<index> from an
|
||||
// account-level extended public key (e.g. the xpub of m/44'/195'/0'). It is
|
||||
// watch-only: an xpub can derive child addresses/public keys but never private
|
||||
// keys, so this is safe to run on an internet-facing service.
|
||||
//
|
||||
// change is 0 for the external (receiving) chain; index is the per-order address
|
||||
// index. Both are non-hardened, which is exactly why the account-level xpub can
|
||||
// derive them.
|
||||
func AddressFromAccountXpub(xpub string, change, index uint32) (string, error) {
|
||||
acct, err := hdkeychain.NewKeyFromString(xpub)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: parse xpub: %w", err)
|
||||
}
|
||||
if acct.IsPrivate() {
|
||||
return "", fmt.Errorf("wallet: expected an xpub (public extended key), got a private one")
|
||||
}
|
||||
chainKey, err := acct.Derive(change)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: derive change %d: %w", change, err)
|
||||
}
|
||||
addrKey, err := chainKey.Derive(index)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: derive index %d: %w", index, err)
|
||||
}
|
||||
pub, err := addrKey.ECPubKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: ec pubkey: %w", err)
|
||||
}
|
||||
return PubKeyToTronAddress(pub.SerializeUncompressed()), nil
|
||||
}
|
||||
|
||||
// PubKeyToTronAddress converts a 65-byte uncompressed secp256k1 public key
|
||||
// (0x04 || X || Y) to a TRON Base58Check address:
|
||||
//
|
||||
// body = 0x41 || keccak256(X||Y)[12:] // last 20 bytes of the Keccak hash
|
||||
// address = Base58( body || dsha256(body)[:4] )
|
||||
//
|
||||
// Note: TRON/Ethereum use *legacy* Keccak-256 (not the finalized SHA3-256).
|
||||
func PubKeyToTronAddress(uncompressed []byte) string {
|
||||
h := sha3.NewLegacyKeccak256()
|
||||
h.Write(uncompressed[1:]) // drop the 0x04 prefix; hash the 64-byte X||Y
|
||||
sum := h.Sum(nil)
|
||||
body := append([]byte{tronAddrPrefix}, sum[12:]...) // 0x41 + last 20 bytes
|
||||
return base58CheckEncode(body)
|
||||
}
|
||||
|
||||
// base58CheckEncode appends a 4-byte double-SHA256 checksum and Base58-encodes.
|
||||
// (TRON's version byte 0x41 is already inside input, so this is a plain
|
||||
// checksum-append, not btcutil's version-byte CheckEncode.)
|
||||
func base58CheckEncode(input []byte) string {
|
||||
first := sha256.Sum256(input)
|
||||
second := sha256.Sum256(first[:])
|
||||
full := make([]byte, 0, len(input)+4)
|
||||
full = append(full, input...)
|
||||
full = append(full, second[:4]...)
|
||||
return base58.Encode(full)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testMnemonic is the canonical all-zero-entropy BIP39 test vector. The derived
|
||||
// TRON addresses logged by TestAccountXpubDeriveConsistency must match
|
||||
// iancoleman.io/bip39 (Coin = TRX, BIP44) — that manual comparison is Phase A.4
|
||||
// of the crypto-tx-engine plan (guards against a derivation mismatch that would
|
||||
// silently send funds to addresses we don't control).
|
||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// TestAccountXpubDeriveConsistency proves the watcher's watch-only path
|
||||
// (xpub -> address) yields exactly the same address as the offline private path
|
||||
// (seed -> privkey -> pubkey -> address) for each index. That equality is what
|
||||
// guarantees every receiving address the watcher hands out is spendable by the
|
||||
// key we hold in cold storage.
|
||||
func TestAccountXpubDeriveConsistency(t *testing.T) {
|
||||
xpub, err := AccountXpubFromMnemonic(testMnemonic, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("account xpub: %v", err)
|
||||
}
|
||||
t.Logf("account xpub (m/44'/195'/0'): %s", xpub)
|
||||
|
||||
acct, err := AccountKeyFromMnemonic(testMnemonic, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("account key: %v", err)
|
||||
}
|
||||
|
||||
for i := uint32(0); i < 5; i++ {
|
||||
viaXpub, err := AddressFromAccountXpub(xpub, 0, i) // what the watcher does
|
||||
if err != nil {
|
||||
t.Fatalf("via xpub [%d]: %v", i, err)
|
||||
}
|
||||
|
||||
ck, err := acct.Derive(0)
|
||||
if err != nil {
|
||||
t.Fatalf("derive change: %v", err)
|
||||
}
|
||||
ak, err := ck.Derive(i)
|
||||
if err != nil {
|
||||
t.Fatalf("derive index %d: %v", i, err)
|
||||
}
|
||||
pub, err := ak.ECPubKey()
|
||||
if err != nil {
|
||||
t.Fatalf("ec pubkey: %v", err)
|
||||
}
|
||||
viaPriv := PubKeyToTronAddress(pub.SerializeUncompressed())
|
||||
|
||||
if viaXpub != viaPriv {
|
||||
t.Fatalf("index %d: xpub-derived %q != priv-derived %q", i, viaXpub, viaPriv)
|
||||
}
|
||||
if !strings.HasPrefix(viaXpub, "T") || len(viaXpub) != 34 {
|
||||
t.Fatalf("index %d: not a valid TRON address: %q", i, viaXpub)
|
||||
}
|
||||
t.Logf("m/44'/195'/0'/0/%d -> %s", i, viaXpub)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownVector locks the derivation to a golden result (filled from the run of
|
||||
// TestAccountXpubDeriveConsistency, then confirmed against iancoleman.io — A.4).
|
||||
// If this ever changes, the derivation implementation regressed.
|
||||
func TestKnownVector(t *testing.T) {
|
||||
if len(goldenAddrs) == 0 {
|
||||
t.Skip("golden vector not yet baked — run TestAccountXpubDeriveConsistency, confirm vs Ian Coleman, then fill goldenAddrs")
|
||||
}
|
||||
xpub, err := AccountXpubFromMnemonic(testMnemonic, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("xpub: %v", err)
|
||||
}
|
||||
if goldenXpub != "" && xpub != goldenXpub {
|
||||
t.Fatalf("account xpub changed:\n got %s\n want %s", xpub, goldenXpub)
|
||||
}
|
||||
for i, want := range goldenAddrs {
|
||||
got, err := AddressFromAccountXpub(xpub, 0, uint32(i))
|
||||
if err != nil {
|
||||
t.Fatalf("addr[%d]: %v", i, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("addr[%d]: got %s want %s", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Golden vector for the "abandon…about" test mnemonic, Coin=TRX, m/44'/195'/0'.
|
||||
// Locks the derivation against regression. ⚠️ MUST be confirmed once against
|
||||
// iancoleman.io/bip39 (Phase A.4) — self-consistency (TestAccountXpubDeriveConsistency)
|
||||
// proves the xpub and private paths agree, but only an independent tool proves
|
||||
// both aren't wrong the same way. If Ian Coleman disagrees, the impl has a bug.
|
||||
var (
|
||||
goldenXpub = "xpub6D1AabNHCupeiLM65ZR9UStMhJ1vCpyV4XbZdyhMZBiJXALQtmn9p42VTQckoHVn8WNqS7dqnJokZHAHcHGoaQgmv8D45oNUKx6DZMNZBCd"
|
||||
goldenAddrs = []string{
|
||||
"TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", // m/44'/195'/0'/0/0
|
||||
"TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK", // m/44'/195'/0'/0/1
|
||||
"TYJPRrdB5APNeRs4R7fYZSwW3TcrTKw2gx", // m/44'/195'/0'/0/2
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
bip39 "github.com/tyler-smith/go-bip39"
|
||||
)
|
||||
|
||||
// ⚠️ OFFLINE ONLY. Everything in this file touches the BIP39 seed / private keys.
|
||||
// It exists for (a) generating the account xpub to hand to the watcher, and
|
||||
// (b) deriving per-address private keys for offline sweep signing (Phase D).
|
||||
// It must NEVER be linked into or run on the internet-facing pangolin-pay
|
||||
// watcher — the hot service only ever handles the account xpub (see derive.go).
|
||||
|
||||
const (
|
||||
purposeBIP44 = 44
|
||||
coinTypeTRON = 195
|
||||
// hardenedOffset marks a derivation index as hardened (requires the private
|
||||
// key). BIP44's first three levels (purpose'/coin'/account') are hardened.
|
||||
hardenedOffset = hdkeychain.HardenedKeyStart // 0x80000000
|
||||
)
|
||||
|
||||
// AccountKeyFromMnemonic derives the account-level extended *private* key at
|
||||
// m/44'/195'/<account>' from a BIP39 mnemonic (+ optional passphrase).
|
||||
// OFFLINE ONLY.
|
||||
func AccountKeyFromMnemonic(mnemonic, passphrase string, account uint32) (*hdkeychain.ExtendedKey, error) {
|
||||
if !bip39.IsMnemonicValid(mnemonic) {
|
||||
return nil, fmt.Errorf("wallet: invalid BIP39 mnemonic (checksum/wordlist)")
|
||||
}
|
||||
seed := bip39.NewSeed(mnemonic, passphrase)
|
||||
master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wallet: master key: %w", err)
|
||||
}
|
||||
for _, step := range []uint32{
|
||||
hardenedOffset + purposeBIP44,
|
||||
hardenedOffset + coinTypeTRON,
|
||||
hardenedOffset + account,
|
||||
} {
|
||||
master, err = master.Derive(step)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wallet: derive account path: %w", err)
|
||||
}
|
||||
}
|
||||
return master, nil
|
||||
}
|
||||
|
||||
// AccountXpubFromMnemonic returns the account-level xpub string to hand to the
|
||||
// watcher. OFFLINE ONLY — run this once on the air-gapped machine, copy only the
|
||||
// returned xpub to the hot service.
|
||||
func AccountXpubFromMnemonic(mnemonic, passphrase string, account uint32) (string, error) {
|
||||
k, err := AccountKeyFromMnemonic(mnemonic, passphrase, account)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pub, err := k.Neuter() // strip the private key -> xpub
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: neuter: %w", err)
|
||||
}
|
||||
return pub.String(), nil
|
||||
}
|
||||
|
||||
// PrivKeyHexFromMnemonic derives the raw secp256k1 private key (hex) for the
|
||||
// address at m/44'/195'/<account>'/<change>/<index>, for offline sweep signing.
|
||||
// OFFLINE ONLY.
|
||||
func PrivKeyHexFromMnemonic(mnemonic, passphrase string, account, change, index uint32) (string, error) {
|
||||
acct, err := AccountKeyFromMnemonic(mnemonic, passphrase, account)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
chainKey, err := acct.Derive(change)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: derive change: %w", err)
|
||||
}
|
||||
addrKey, err := chainKey.Derive(index)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: derive index: %w", err)
|
||||
}
|
||||
priv, err := addrKey.ECPrivKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: ec privkey: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", priv.Serialize()), nil
|
||||
}
|
||||
|
||||
// AddressFromMnemonic derives the TRON address at m/44'/195'/<account>'/<change>/<index>
|
||||
// straight from the mnemonic. OFFLINE ONLY — used by the sweep signer to verify
|
||||
// that a derived key matches the address it is about to sign for.
|
||||
func AddressFromMnemonic(mnemonic, passphrase string, account, change, index uint32) (string, error) {
|
||||
acct, err := AccountKeyFromMnemonic(mnemonic, passphrase, account)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
chainKey, err := acct.Derive(change)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: derive change: %w", err)
|
||||
}
|
||||
addrKey, err := chainKey.Derive(index)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: derive index: %w", err)
|
||||
}
|
||||
pub, err := addrKey.ECPubKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: ec pubkey: %w", err)
|
||||
}
|
||||
return PubKeyToTronAddress(pub.SerializeUncompressed()), nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package watcher polls TronGrid for incoming USDT and marks paid orders.
|
||||
// It only reads the chain and flips order state — it never holds keys or moves
|
||||
// funds (sweeping is a separate offline step).
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
"github.com/wangjia/pangolin/pay/internal/tron"
|
||||
)
|
||||
|
||||
type Watcher struct {
|
||||
st *store.Store
|
||||
tron tron.Fetcher
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(st *store.Store, f tron.Fetcher, log *slog.Logger) *Watcher {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Watcher{st: st, tron: f, log: log, now: time.Now}
|
||||
}
|
||||
|
||||
// Tick: (1) expire overdue pending orders; (2) for each still-pending order,
|
||||
// look for a confirmed incoming transfer >= the expected amount on its unique
|
||||
// address and mark it paid. Idempotent — a transfer seen twice flips the order
|
||||
// at most once (MarkPaid only affects a still-pending row).
|
||||
func (w *Watcher) Tick(ctx context.Context) error {
|
||||
if n, err := w.st.MarkExpired(ctx, w.now()); err != nil {
|
||||
return err
|
||||
} else if n > 0 {
|
||||
w.log.Info("orders expired", "count", n)
|
||||
}
|
||||
|
||||
pending, err := w.st.ListPending(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, o := range pending {
|
||||
transfers, err := w.tron.IncomingTransfers(ctx, o.Address)
|
||||
if err != nil {
|
||||
// Transient (rate limit / network): log and move on; retried next tick.
|
||||
w.log.Warn("fetch transfers failed", "order", o.OrderNo, "err", err)
|
||||
continue
|
||||
}
|
||||
for _, t := range transfers {
|
||||
if t.Value < o.ExpectAmount {
|
||||
continue
|
||||
}
|
||||
ok, err := w.st.MarkPaid(ctx, o.OrderNo, t.TxID)
|
||||
if err != nil {
|
||||
w.log.Error("mark paid", "order", o.OrderNo, "err", err)
|
||||
break
|
||||
}
|
||||
if ok {
|
||||
w.log.Info("order paid", "order", o.OrderNo, "tx", t.TxID, "value", t.Value, "address", o.Address)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Loop runs Tick every interval until ctx is cancelled.
|
||||
func (w *Watcher) Loop(ctx context.Context, interval time.Duration) {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := w.Tick(ctx); err != nil {
|
||||
w.log.Error("watcher tick", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
"github.com/wangjia/pangolin/pay/internal/tron"
|
||||
)
|
||||
|
||||
type mockFetcher struct{ m map[string][]tron.Transfer }
|
||||
|
||||
func (f *mockFetcher) IncomingTransfers(_ context.Context, addr string) ([]tron.Transfer, error) {
|
||||
return f.m[addr], nil
|
||||
}
|
||||
|
||||
func newPending(t *testing.T, st *store.Store, orderNo, addr string, amount int64, expires time.Time) {
|
||||
t.Helper()
|
||||
o := &store.Order{
|
||||
OrderNo: orderNo, SKU: "pro", ExpectAmount: amount, Address: addr,
|
||||
Status: store.StatusPending, CreatedAt: time.Unix(1_700_000_000, 0), ExpiresAt: expires,
|
||||
}
|
||||
if err := st.CreateOrder(context.Background(), o); err != nil {
|
||||
t.Fatalf("seed order: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcherMarksPaidOnSufficientTransfer(t *testing.T) {
|
||||
st, _ := store.Open(":memory:")
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_100, 0)
|
||||
|
||||
newPending(t, st, "PAY1", "TADDR1", 5_000000, now.Add(time.Hour))
|
||||
fetch := &mockFetcher{m: map[string][]tron.Transfer{}}
|
||||
w := New(st, fetch, nil)
|
||||
w.now = func() time.Time { return now }
|
||||
|
||||
// No transfer yet -> stays pending.
|
||||
if err := w.Tick(ctx); err != nil {
|
||||
t.Fatalf("tick1: %v", err)
|
||||
}
|
||||
if o, _ := st.GetOrder(ctx, "PAY1"); o.Status != store.StatusPending {
|
||||
t.Fatalf("want pending, got %s", o.Status)
|
||||
}
|
||||
|
||||
// Underpayment -> still pending.
|
||||
fetch.m["TADDR1"] = []tron.Transfer{{TxID: "tx-under", To: "TADDR1", Value: 4_000000}}
|
||||
_ = w.Tick(ctx)
|
||||
if o, _ := st.GetOrder(ctx, "PAY1"); o.Status != store.StatusPending {
|
||||
t.Fatalf("underpay should stay pending, got %s", o.Status)
|
||||
}
|
||||
|
||||
// Sufficient payment -> paid, tx recorded.
|
||||
fetch.m["TADDR1"] = []tron.Transfer{{TxID: "tx-ok", To: "TADDR1", Value: 5_000000}}
|
||||
_ = w.Tick(ctx)
|
||||
o, _ := st.GetOrder(ctx, "PAY1")
|
||||
if o.Status != store.StatusPaid || o.TxID != "tx-ok" {
|
||||
t.Fatalf("want paid/tx-ok, got %s/%s", o.Status, o.TxID)
|
||||
}
|
||||
|
||||
// Idempotent: another tick with same transfer doesn't error or flip anything.
|
||||
if err := w.Tick(ctx); err != nil {
|
||||
t.Fatalf("idempotent tick: %v", err)
|
||||
}
|
||||
o, _ = st.GetOrder(ctx, "PAY1")
|
||||
if o.Status != store.StatusPaid || o.TxID != "tx-ok" {
|
||||
t.Fatalf("idempotency broken: %s/%s", o.Status, o.TxID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcherExpiresOverdue(t *testing.T) {
|
||||
st, _ := store.Open(":memory:")
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_100, 0)
|
||||
|
||||
newPending(t, st, "OLD", "TADDR2", 1_000000, now.Add(-time.Minute)) // already overdue
|
||||
w := New(st, &mockFetcher{m: map[string][]tron.Transfer{}}, nil)
|
||||
w.now = func() time.Time { return now }
|
||||
|
||||
if err := w.Tick(ctx); err != nil {
|
||||
t.Fatalf("tick: %v", err)
|
||||
}
|
||||
if o, _ := st.GetOrder(ctx, "OLD"); o.Status != store.StatusExpired {
|
||||
t.Fatalf("want expired, got %s", o.Status)
|
||||
}
|
||||
}
|
||||
Executable
+43
@@ -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"
|
||||
}
|
||||
@@ -20,6 +20,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<html lang="en" data-theme="light" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* 首屏渲染前据 localStorage 设好 data-theme/lang,避免明暗主题(整页配色)在
|
||||
挂载后才应用造成的 FOUC 闪烁。/user/* CSP 允许 'unsafe-inline' 脚本,故直接内联。
|
||||
与官网 Site.astro 的 no-FOUC 脚本同法(键名用用户中心的 pg_uc_theme/pg_uc_lang)。 */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var t=localStorage.getItem('pg_uc_theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t;var l=localStorage.getItem('pg_uc_lang');if(l)document.documentElement.lang=l;}catch(e){}})()",
|
||||
}}
|
||||
/>
|
||||
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
|
||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
||||
<link rel="stylesheet" href={`${BASE_PATH}/colors_and_type.css`} />
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function Invite({ t }: { t: TFn }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('inviteTitle')}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--fg3)', marginTop: 5, lineHeight: 1.6 }}>{t('inviteSub')}</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function Overview({
|
||||
const free = me.plan === 'free';
|
||||
const vals = me.weeklyGB;
|
||||
const max = Math.max(...vals, 0.1);
|
||||
const labels = lang === 'zh' ? ['一', '二', '三', '四', '五', '六', '日'] : ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
||||
const labels = lang === 'zh' ? ['一', '二', '三', '四', '五', '六', '日'] : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
const stats: [string, string, string, string][] = [
|
||||
['clock', t('quotaToday'), free ? String(me.quotaTodayMin ?? 0) : '∞', free ? 'min' : ''],
|
||||
@@ -76,13 +76,13 @@ export default function Overview({
|
||||
</div>
|
||||
|
||||
{/* usage chart + quick actions */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1.6fr 1fr', gap: 14, alignItems: 'start' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1.6fr 1fr', gap: 14, alignItems: 'stretch' }}>
|
||||
<div style={{ ...card, padding: '18px 22px' }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)', marginBottom: 16 }}>{t('usageTitle')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 14, height: 110 }}>
|
||||
{vals.map((v, i) => (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9.5, color: 'var(--fg3)' }}>{v}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9.5, color: 'var(--fg3)' }}>{Number(v.toPrecision(2))}</div>
|
||||
<div style={{ width: '100%', maxWidth: 30, height: `${(v / max) * 76}px`, background: 'var(--accent)', borderRadius: '5px 5px 0 0', opacity: 0.85 }} />
|
||||
<div style={{ fontSize: 10.5, color: 'var(--fg3)' }}>{labels[i]}</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function Redeem({ t, lang, onRedeemed }: { t: TFn; lang: Lang; on
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('navRedeem')}</div>
|
||||
<div style={{ ...card, padding: '20px 22px' }}>
|
||||
<div style={{ fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)', marginBottom: 12 }}>{t('redeemTitle')}</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Icon } from './icons';
|
||||
import { card, input } from './shared';
|
||||
import { ErrorLine } from './Login';
|
||||
import { useUI } from '../lib/theme';
|
||||
import type { TFn, Lang } from '../lib/i18n';
|
||||
import { getClient } from '../lib/api/client';
|
||||
import { bilingual } from '../lib/api/errors';
|
||||
@@ -13,9 +12,8 @@ import type { Device, TotpSetup } from '../lib/api/types';
|
||||
|
||||
export default function Settings({ t, lang, totpEnabled, onTotpChange }: { t: TFn; lang: Lang; totpEnabled: boolean; onTotpChange: () => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('settingsTitle')}</div>
|
||||
<Preferences t={t} />
|
||||
<TotpSection t={t} lang={lang} enabled={totpEnabled} onChange={onTotpChange} />
|
||||
<Devices t={t} lang={lang} />
|
||||
</div>
|
||||
@@ -24,57 +22,6 @@ export default function Settings({ t, lang, totpEnabled, onTotpChange }: { t: TF
|
||||
|
||||
const sectionTitle: React.CSSProperties = { fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)' };
|
||||
|
||||
function Preferences({ t }: { t: TFn }) {
|
||||
const { lang, setLang, theme, setTheme } = useUI();
|
||||
return (
|
||||
<div style={{ ...card, padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={sectionTitle}>{t('prefTitle')}</div>
|
||||
<Row label={t('prefLang')}>
|
||||
<Seg
|
||||
value={lang}
|
||||
options={[['zh', '中文'], ['en', 'EN']]}
|
||||
onPick={(v) => setLang(v as Lang)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label={t('prefTheme')}>
|
||||
<Seg
|
||||
value={theme}
|
||||
options={[['light', t('themeLight')], ['dark', t('themeDark')]]}
|
||||
icons={{ light: 'sun', dark: 'moon' }}
|
||||
onPick={(v) => setTheme(v as 'light' | 'dark')}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg2)' }}>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Seg({ value, options, onPick, icons }: { value: string; options: [string, string][]; onPick: (v: string) => void; icons?: Record<string, string> }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', background: 'var(--bg-subtle)', borderRadius: 999, padding: 3, gap: 2 }}>
|
||||
{options.map(([v, l]) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => onPick(v)}
|
||||
aria-pressed={value === v}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 'none', cursor: 'pointer', borderRadius: 999, padding: '6px 14px', fontFamily: 'var(--font-sans)', fontSize: 12.5, fontWeight: 700, background: value === v ? 'var(--accent)' : 'transparent', color: value === v ? 'var(--fg-on-accent)' : 'var(--fg3)' }}
|
||||
>
|
||||
{icons && <Icon name={icons[v]} size={14} color={value === v ? 'var(--fg-on-accent)' : 'var(--fg3)'} />}
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── TOTP 2FA ───────── */
|
||||
function TotpSection({ t, lang, enabled, onChange }: { t: TFn; lang: Lang; enabled: boolean; onChange: () => void }) {
|
||||
const api = getClient();
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function Subscription({ t, mobile }: { t: TFn; mobile: boolean })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 720 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('subTitle')}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--fg3)', marginTop: 5, lineHeight: 1.6 }}>{t('subDesc')}</div>
|
||||
|
||||
@@ -12,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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -23,6 +23,7 @@ const plansData = {
|
||||
feats: [t('pf.free1'), t('pf.free2'), t('pf.free3'), t('pf.free4')],
|
||||
cta: t('price.cta_free'),
|
||||
ctaClass: 'pcta-out',
|
||||
href: '#download',
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
@@ -33,6 +34,7 @@ const plansData = {
|
||||
feats: [t('pf.pro1'), t('pf.pro2'), t('pf.pro3'), t('pf.pro4'), t('pf.pro5')],
|
||||
cta: t('price.cta_pro'),
|
||||
ctaClass: 'pcta-white',
|
||||
href: '#get-code',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
@@ -43,6 +45,7 @@ const plansData = {
|
||||
feats: [t('pf.team1'), t('pf.team2'), t('pf.team3'), t('pf.team4')],
|
||||
cta: t('price.cta_team'),
|
||||
ctaClass: 'pcta-fill',
|
||||
href: '#get-code',
|
||||
highlight: false,
|
||||
},
|
||||
],
|
||||
@@ -75,7 +78,7 @@ function cell(v: string) {
|
||||
|
||||
<div class="wrap">
|
||||
<!-- payment note -->
|
||||
<div class="pay-note">
|
||||
<div class="pay-note" id="get-code">
|
||||
<Icon name="shield-check" />
|
||||
<div>
|
||||
<b>{t('pay.title')}</b><br>
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function PricingPlans({ data }) {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button class={`pcta ${p.ctaClass}`}>{p.cta}</button>
|
||||
<a href={p.href || '#get-code'} class={`pcta ${p.ctaClass}`}>{p.cta}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -42,6 +42,11 @@ img,svg{display:block}
|
||||
.linklogin{font-size:14.5px;font-weight:600;color:var(--fg1);cursor:pointer}
|
||||
.linklogin:hover{color:var(--accent)}
|
||||
|
||||
/* 明/暗主题切换(顶栏图标按钮,随 token 自适配) */
|
||||
.themetoggle{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;border:none;background:transparent;color:var(--fg2);cursor:pointer;padding:6px;transition:color var(--dur-fast) var(--ease-out)}
|
||||
.themetoggle:hover{color:var(--accent)}
|
||||
.themetoggle svg{width:17px;height:17px}
|
||||
|
||||
/* ---------- language dropdown ---------- */
|
||||
.langwrap{position:relative;display:inline-block}
|
||||
.langsel{display:inline-flex;align-items:center;gap:6px;border:1.5px solid var(--border-strong);border-radius:var(--radius-full);padding:8px 14px;background:var(--surface);color:var(--fg1);font-family:var(--font-sans);font-size:14px;font-weight:600;cursor:pointer;transition:border-color var(--dur-fast) var(--ease-out),color var(--dur-fast) var(--ease-out)}
|
||||
@@ -166,7 +171,7 @@ section{padding:88px 0}
|
||||
.plan.feat-plan .feats li{color:rgba(255,255,255,.92)}
|
||||
.plan .feats svg{width:16px;height:16px;color:var(--success);flex-shrink:0;margin-top:1px}
|
||||
.plan.feat-plan .feats svg{color:#fff}
|
||||
.plan .pcta{width:100%;border:none;border-radius:var(--radius-full);padding:13px;font-family:var(--font-sans);font-weight:700;font-size:14.5px;cursor:pointer}
|
||||
.plan .pcta{display:block;width:100%;box-sizing:border-box;text-align:center;text-decoration:none;border:none;border-radius:var(--radius-full);padding:13px;font-family:var(--font-sans);font-weight:700;font-size:14.5px;cursor:pointer}
|
||||
.pcta-fill{background:var(--accent);color:#fff}
|
||||
.pcta-white{background:#fff;color:var(--clay-700)}
|
||||
.pcta-out{background:transparent;color:var(--fg2);border:1.5px solid var(--border-strong)!important}
|
||||
|
||||
Reference in New Issue
Block a user