Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5949a595a | |||
| 543a54c606 | |||
| 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-single-address-plan.html">
|
||||
<div class="t">pangolin-pay 单地址 + 唯一金额收款模型(定稿,#34/34A)<span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-09-pay-single-address.md</code>(含 checkbox)。<b>定稿:单个固定收款地址 + 每单唯一金额</b>(取代每单唯一 HD 地址/地址池)。归集=1 地址(激活一次、扫一笔)最省;订单靠金额区分。唯一金额=base+微尾数(≤0.01 USDT);精确 == 匹配 + 时间戳(晚于建单)防迟到误配;付错→孤儿人工对账。前端契约不变(POST /order 返回 address+expect_amount)→ 以后升多地址/GasFree 纯后端切、前端零改。5 Phase:数据模型→建单→watcher 匹配/超时/孤儿→归集(能量租赁)→并发/付错真链验证。</div>
|
||||
<div class="path">docs/pay-single-address-plan.html · 真相源 docs/superpowers/plans/2026-07-09-pay-single-address.md</div>
|
||||
</a>
|
||||
<a class="doc" href="crypto-tx-engine-plan.html">
|
||||
<div class="t">pangolin-pay 加密货币交易引擎(#34 第一块,可独立验证)<span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-09-crypto-tx-engine.md</code>(含 checkbox)。#34 里"加密货币交易"核心的独立可执行件:钱包(两套助记词 A 运营/B 金库,离线生成、热服务只持 xpub)→ Go 服务 pangolin-pay HD 派生收款地址(对齐 Ian Coleman)→ TronGrid watcher 侦测 TRC20 到账(每单唯一地址+金额、确认、幂等)→ 归集 sweep(离线签名、TRON gas 两步/能量租赁)→ 真实 1 USDT 端到端验证。不含独角数卡/发码,跑通后再接门面。</div>
|
||||
<div class="path">docs/crypto-tx-engine-plan.html · 真相源 docs/superpowers/plans/2026-07-09-crypto-tx-engine.md</div>
|
||||
</a>
|
||||
<a class="doc" href="payment-clean-usdt-plan.html">
|
||||
<div class="t">收款闭环 · 独角数卡 + 自托管 USDT(TRC20) + webhook JIT(#34)<span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-09-payment-clean-usdt-loop.md</code>(含 checkbox)。最干净长期方案:门面独角数卡(独立海外 VPS)+ 自托管 TRC20 HD 钱包(热服务只持 xpub、watch-only,绝不持私钥)+ 自建 TronGrid watcher(每单唯一地址侦测到账)+ webhook JIT 发码(新增控制面 <code>POST /internal/codes/mint</code>,HMAC,售出才产合法码)。复用已就绪 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,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pangolin-pay 单地址 + 唯一金额收款模型 实现计划</title>
|
||||
<style>
|
||||
:root{--bg:#0f1117;--panel:#171a22;--panel2:#1d2129;--fg:#e6e8ee;--fg2:#a8afbd;--accent:#e0884f;--accent2:#5fb0c9;--ok:#5ec27a;--bad:#e06a6a;--warn:#e0b84f;--border:#272c36;--mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:28px;line-height:1.3;margin:0 0 8px}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:20px;margin:40px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border);color:var(--accent)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.85em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px}
|
||||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
ul.ck{list-style:none;padding-left:4px}
|
||||
ul.ck li{position:relative;padding-left:26px;margin:7px 0}
|
||||
ul.ck li::before{content:"☐";position:absolute;left:0;color:var(--fg2)}
|
||||
ul{padding-left:22px} li{margin:6px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
a{color:var(--accent2)} .back{display:inline-block;margin-bottom:24px;font-size:13px} b{color:#fff}
|
||||
.okbox{background:rgba(94,194,122,.07);border:1px solid rgba(94,194,122,.32);border-radius:12px;padding:14px 18px;margin:14px 0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<a class="back" href="index.html">← 文档索引</a>
|
||||
<h1>pangolin-pay 单地址 + 唯一金额收款模型</h1>
|
||||
<p class="sub">2026-07-09 · <span class="tag info">阅读版</span> · 执行真相源 <code>docs/superpowers/plans/2026-07-09-pay-single-address.md</code> · #34/34A 收款模型定稿</p>
|
||||
|
||||
<div class="lead">
|
||||
<b>定稿:单个固定收款地址 + 每单唯一金额。</b> 归集 = 1 个地址(激活一次、扫一笔),成本最省;订单靠<b>金额</b>区分,不靠地址。前端契约不变(<code>POST /order</code> 返回 <code>address+expect_amount</code>)→ 以后升多地址/GasFree 是<b>纯后端换实现</b>,前端零改动。
|
||||
</div>
|
||||
|
||||
<div class="okbox">
|
||||
<b>唯一金额:</b><code>expect_amount = base + tail</code>,base=价格×1e6(micro-USDT),<b>tail∈[1,9999] micro</b>(偏差<0.01 USDT,价格几乎不变),迟到窗口内不复用。<b>匹配=精确 ==</b>:找"到收款地址、value==expect_amount、block_ts>建单"的转入 → paid。支付页显示<b>可复制的精确金额</b>。付错/迟到抹尾数 → 孤儿人工对账。
|
||||
</div>
|
||||
|
||||
<h2>Phase A — 数据模型</h2>
|
||||
<ul class="ck">
|
||||
<li><b>A.1</b> pay_orders 加 user_ref / expect_amount(唯一金额)/ matched_tx_id;address 恒为收款地址。</li>
|
||||
<li><b>A.2</b> tail 分配支撑:活跃订单已用 tail + 近期冷却(迟到窗口不复用)。</li>
|
||||
<li><b>A.3</b> orphan_payments(tx_id唯一/value/block_ts/handled)。</li>
|
||||
<li><b>A.4</b> 配置 PAY_RECEIVE_ADDRESS(或从 xpub 派生 index 0)。</li>
|
||||
</ul>
|
||||
<h2>Phase B — 建单</h2>
|
||||
<ul class="ck">
|
||||
<li><b>B.1</b> CreateOrder(user, sku, price):同用户无活跃单 → 分配唯一金额 → 写 pending(TTL 15min)。</li>
|
||||
<li><b>B.2</b> tail 分配(活跃间不撞 + 迟到窗口不复用);GetOrder。</li>
|
||||
</ul>
|
||||
<h2>Phase C — watcher</h2>
|
||||
<ul class="ck">
|
||||
<li><b>C.1</b> 匹配:pending 单查收款地址转入,value==expect_amount 且 block_ts>created → paid + tx。</li>
|
||||
<li><b>C.2</b> 超时 → expired。</li>
|
||||
<li><b>C.3</b> 孤儿:到账不匹配任何活跃订单 → orphan_payments(去重)。</li>
|
||||
<li><b>C.4</b> 幂等 + 崩溃恢复;TronGrid 取 block_timestamp。</li>
|
||||
</ul>
|
||||
<h2>Phase D — 归集</h2>
|
||||
<ul class="ck"><li><b>D.1</b> 扫收款地址余额 → 冷钱包(能量租赁);一地址一笔。</li></ul>
|
||||
<h2>Phase E — 验证</h2>
|
||||
<ul class="ck">
|
||||
<li><b>E.1</b> 单测:唯一金额不撞、精确匹配、付错/迟到成孤儿、超时、同用户单订单、幂等、时间戳过滤。</li>
|
||||
<li><b>E.2</b> 真链:两并发订单不同金额同地址各 paid;付错成孤儿;归集。</li>
|
||||
</ul>
|
||||
<h2>不在本轮</h2>
|
||||
<ul><li>多地址/地址池、GasFree(#35)、能量租赁自动化、发货侧 —— 以后需要纯后端切,前端不动。</li></ul>
|
||||
<p class="small" style="margin-top:32px">相关:<a href="crypto-tx-engine-plan.html">加密货币交易引擎(已实现钱包/tron/sweep)</a> · <a href="payment-channels-overview.html">渠道选型</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
# pangolin-pay 单地址 + 唯一金额收款模型(定稿)
|
||||
|
||||
> #34/34A 收款模型**定稿**。取代"每单唯一 HD 地址"(激活/归集随订单数线性涨)与"地址池"(仍多地址)。
|
||||
> 最终选:**单个固定收款地址 + 每单唯一金额**。归集 = **1 个地址**(激活一次、扫一笔),成本最省;
|
||||
> 订单靠**金额**区分,不靠地址。
|
||||
>
|
||||
> **前端契约不变**:`POST /order` 仍返回 `{address, expect_amount}`,client 只用每单返回值 → 以后要升
|
||||
> 多地址/GasFree 是**纯后端换实现**,前端零改动(见下"契约约定")。
|
||||
>
|
||||
> 已实现的 wallet 派生 / tron(TronGrid 读到账、建交易、签名)/ cmd/sweep 复用;主要改 **store / pay(建单)/
|
||||
> watcher(匹配)**。现有代码是"每单派生新址",本轮改为"单地址 + 唯一金额"。
|
||||
|
||||
## 决策(已定)
|
||||
|
||||
- **收款地址**:钱包 A 的地址 0(`m/44'/195'/0'/0/0`),从配置注入或由 xpub 派生。所有订单收到**这一个地址**。
|
||||
- **唯一金额** ⭐:`expect_amount = base + tail`。base = 价格 ×1e6(micro-USDT);**tail 取方案 A(微尾数)**:
|
||||
`tail ∈ [1, 9999]` micro(偏差 ≤ 0.009999 USDT,**价格几乎不变**)。tail 在**迟到窗口内不复用**。
|
||||
- **匹配 = 精确 `==`**:watcher 找"到收款地址、`value == expect_amount`、`block_ts > order.created`"的 TRC20 转入 → paid。
|
||||
支付页显示**可一键复制的精确金额** + "请付精确金额"提示。
|
||||
- **15min 超时** → expired → 提示重建订单(该 tail 一段时间内不复用)。
|
||||
- **同用户单订单**:同时只能一个活跃(pending)订单。
|
||||
- **孤儿付款**:到该地址但 `value` 不匹配任何活跃订单(付错/迟到抹了尾数)→ 记 `orphan_payments`,人工对账/补发。
|
||||
- **归集**:定期扫**这一个地址**余额 → 冷钱包(**能量租赁** ~$0.1–1/笔)。激活一次、归集一笔。
|
||||
|
||||
## 契约约定(钉死,保证以后单↔多地址纯后端)
|
||||
|
||||
> **client(下单页/独角数卡)只使用每单 `POST /order` 返回的 `address` + `expect_amount`,绝不硬编码/缓存地址。**
|
||||
> 只要守住这条,单地址 ↔ 多地址 ↔ GasFree 都是后端内部换实现,前端与 VPN app 都不改。
|
||||
|
||||
## Phase A — 数据模型
|
||||
|
||||
- [ ] `pay_orders` 改:加 `user_ref`、`expect_amount`(唯一金额,micro-USDT)、`matched_tx_id`;`address` 恒为收款地址。
|
||||
- [ ] tail 分配支撑:记录**当前活跃订单已用 tail**(避免撞)+ **近期已用 tail 冷却**(迟到窗口内不复用);或全局计数器 + 去重校验。
|
||||
- [ ] `orphan_payments` 表:`tx_id(唯一) / value / block_ts / created_at / handled(bool)`。
|
||||
- [ ] 配置:`PAY_RECEIVE_ADDRESS`(或从 `PAY_ACCOUNT_XPUB` 派生 index 0,与钱包 A 地址 0 一致)。
|
||||
|
||||
## Phase B — 建单(pay 服务)
|
||||
|
||||
- [ ] `CreateOrder(userRef, sku, priceMicro)`:① 校验**同用户无活跃单**(有则返回现有/拒);② 分配**唯一金额**(base + 未占用 tail);③ 写 `pending` 单(TTL 15min),`address` = 收款地址。
|
||||
- [ ] **tail 分配**:与当前所有活跃订单不撞 + 迟到窗口内不复用(记录+回收)。
|
||||
- [ ] `GetOrder`。
|
||||
|
||||
## Phase C — watcher 改造(匹配 / 超时 / 孤儿)
|
||||
|
||||
- [ ] **匹配**:对每个 `pending` 单,查**收款地址**的 TRC20 转入,筛 `value == expect_amount` **且 `block_ts > order.created`** → `paid` + 记 `matched_tx_id`。
|
||||
- [ ] **超时**:`pending` 过期 → `expired`。
|
||||
- [ ] **孤儿**:收款地址的到账 tx 匹配不到任何活跃订单 → 记 `orphan_payments`(按 tx_id 去重)。
|
||||
- [ ] **幂等**(tx_id 全局去重,含 orphan)+ **崩溃恢复**;TronGrid 需取 `block_timestamp` 供时间过滤。
|
||||
|
||||
## Phase D — 归集(复用 cmd/sweep)
|
||||
|
||||
- [ ] 扫**收款地址**余额 → 冷钱包(**能量租赁**,先手动 runbook);一地址一笔,与订单状态解耦。
|
||||
|
||||
## Phase E — 测试 + 真链验证
|
||||
|
||||
- [ ] 单测:唯一金额分配不撞、精确匹配、**付错/迟到金额成孤儿**、超时、同用户单订单、幂等、**时间戳过滤**(旧到账不误配新单)、崩溃恢复。
|
||||
- [ ] 真链:两并发订单**不同唯一金额、同一地址**各自付款到 paid;故意**付错金额** → 成孤儿不误配;归集该地址到冷钱包。
|
||||
|
||||
## Verification / 判据
|
||||
|
||||
- `go test ./...`(store/pay/watcher 新逻辑全绿,重点覆盖唯一金额/精确匹配/孤儿/时间戳)。
|
||||
- 真链:并发不同金额 + 付错成孤儿 两场景正确;归集一笔搞定。
|
||||
- 成本:激活一次、归集 O(1);小额高频不再被激活/归集费拖累。
|
||||
|
||||
## 不在本轮 / 以后(纯后端可切)
|
||||
|
||||
- 多地址 / 地址池(容错更好但更贵,以后纯后端切,前端不动)。
|
||||
- GasFree(#35,免 TRX 但每笔固定 1 USDT,适合少地址批量)。
|
||||
- 能量租赁自动化;发货侧(独角数卡 + /internal/codes/mint)另排。
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
# pangolin-pay
|
||||
|
||||
自托管 USDT(TRC20) 收款服务。**单个固定收款地址 + 每单唯一金额**:所有订单收到同一个地址,
|
||||
靠**唯一金额**(基准价 + 微尾数,≤0.01 USDT)区分。watcher 轮询 TronGrid,按
|
||||
**"到账 tx + 精确金额 + 区块时间晚于建单"** 匹配订单;匹配不到活跃订单的到账(付错/迟到)→
|
||||
记 `orphan_payments` 人工对账。归集(把钱扫到冷钱包)是**独立离线步骤**,本服务**不持私钥**。
|
||||
|
||||
> 收款地址 = 钱包 A 的地址 0(`m/44'/195'/0'/0/0`),配置注入或由 xpub 派生。
|
||||
> 前端契约(`POST /order` 返回 address+expect_amount)与地址模型解耦——以后要升多地址/GasFree
|
||||
> 是纯后端换实现,前端零改动。模型定稿见 `docs/pay-single-address-plan.html`。
|
||||
|
||||
> 属 #34「独角数卡 + USDT 收款闭环」的加密货币交易引擎(计划见
|
||||
> `docs/superpowers/plans/2026-07-09-crypto-tx-engine.md`)。概念见 brain
|
||||
> `notes/dev/crypto-hd-wallet-basics.html`。
|
||||
|
||||
## 安全模型(必读)
|
||||
|
||||
- 本服务(热、联网)**只持 account xpub**——能派生收款地址、能查到账,**拿不到任何私钥**。被脱库也转不走钱。
|
||||
- 私钥/助记词**冷存**;要动钱(归集)时才在**离线端**用私钥签名(见 seed.go,OFFLINE ONLY)。
|
||||
- 密钥(xpub / TronGrid key)走 Bitwarden,不入 git、不写死。
|
||||
|
||||
## Phase A —— 离线准备钱包(你在断网机器上做)
|
||||
|
||||
1. 断网,用 Ian Coleman `bip39-standalone.html`(或 `bip_utils`)生成**两套** 24 词助记词:
|
||||
钱包 A(运营收款)、钱包 B(冷备金库)。Coin=TRX、English。
|
||||
2. 取**钱包 A 的 Account Extended Public Key**(`m/44'/195'/0'` 的 xpub)→ 就是本服务的 `PAY_ACCOUNT_XPUB`。
|
||||
3. 取**钱包 B 的地址0**(`T...`)→ 归集目标(Phase D 用)。
|
||||
4. **交叉核对(关键)**:本仓自带的金标准向量(测试助记词 `abandon…about`)必须与 Ian Coleman
|
||||
一致 —— 跑 `go test ./internal/wallet -run TestKnownVector -v`,再在 Ian Coleman 里用同一测试
|
||||
助记词、Coin=TRX 对照前 3 个地址。一致 = 派生实现可信;不一致 = 有 bug,别上线。
|
||||
5. 助记词 A/B 分开冷存 + Bitwarden。**只把 xpub_A 交给本服务。**
|
||||
|
||||
## Money-critical 验证(收任何真钱之前必须全过)
|
||||
|
||||
派生错 = 把买家的钱打到你没私钥的地址,钱直接丢。上真钱前逐步核死:
|
||||
|
||||
**① 代码自检(金标准向量)**
|
||||
```bash
|
||||
cd pay && go test ./internal/wallet -run TestKnownVector -v # 必须 PASS
|
||||
```
|
||||
|
||||
**② 独立交叉核对(证明代码 vs 独立实现一致)** —— 断网开 Ian Coleman `bip39-standalone.html`,
|
||||
输入测试助记词 `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`,
|
||||
Coin=TRX、BIP44,核对 Derived Addresses 前 3 行必须等于:
|
||||
```
|
||||
m/44'/195'/0'/0/0 TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH
|
||||
m/44'/195'/0'/0/1 TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK
|
||||
m/44'/195'/0'/0/2 TYJPRrdB5APNeRs4R7fYZSwW3TcrTKw2gx
|
||||
```
|
||||
且 Account Extended Public Key = `xpub6D1AabNHCupeiLM65ZR9UStMhJ1vCpyV4XbZdyhMZBiJXALQtmn9p42VTQckoHVn8WNqS7dqnJokZHAHcHGoaQgmv8D45oNUKx6DZMNZBCd`。
|
||||
|
||||
**③ 你的真钱包 A(护你自己钱的那道闸,最重要)** —— 用你钱包 A 的真 account xpub:
|
||||
```bash
|
||||
PAY_ACCOUNT_XPUB="<你钱包A的account xpub>" go run ./cmd/paywatch selfcheck 5
|
||||
```
|
||||
把输出的地址,和你钱包 A(在 Ian Coleman 里同一 xpub、或 TronLink/imToken 里钱包 A)显示的
|
||||
地址 0..4 **逐个比对**。**全部一致才可上线收款;有一个不一致,立即停,别收任何钱。**
|
||||
|
||||
**④ 真链闭环(Phase E,最终证明)** —— 小额:
|
||||
1. `POST /order` 拿地址 → 往它转 **1 USDT(TRC20)**。
|
||||
2. 用你钱包 A 打开 → **确认这 1 USDT 出现在你钱包 A 名下**(证明你确实控制这个地址)。
|
||||
3. `GET /order/{id}` 应变 `paid`(证明 watcher 侦测正确)。
|
||||
4. 用 `cmd/sweep` 把这 1 USDT 归集到冷钱包 B → **确认 B 收到**(证明签名/广播正确)。
|
||||
|
||||
全过 = 收→控→测→归 四环闭合,可放量。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
export PAY_ACCOUNT_XPUB="xpub..." # 钱包 A 的 account xpub(必填)
|
||||
export TRONGRID_API_KEY="..." # TronGrid key(建议)
|
||||
export PAY_DB="pay.db" # SQLite 路径(默认 pay.db)
|
||||
export PAY_ADDR=":8090" # 监听(默认 :8090)
|
||||
export PAY_POLL_SECONDS="20" # 轮询间隔秒(默认 20)
|
||||
# USDT_CONTRACT / TRONGRID_BASE 默认主网
|
||||
go run ./cmd/paywatch
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
POST /order {"user_ref":"buyer123","sku":"pro-year","amount":5000000} # amount = 基准价 micro-USDT(1e-6)
|
||||
→ 201 {"order_no","address","expect_amount","status":"pending","expires_at"}
|
||||
# expect_amount = 基准 + 唯一微尾数;支付页要显示"请付精确金额 expect_amount"
|
||||
→ 409 {"error":"user already has an active order"} # 同一 user_ref 同时只能一个活跃订单
|
||||
GET /order/{orderNo} → 200 {..., "status":"pending|paid|expired","tx_id"}
|
||||
GET /healthz → 200 ok
|
||||
```
|
||||
|
||||
门面(独角数卡)下单时调 `POST /order`(带 `user_ref`)拿 **address + expect_amount**;
|
||||
支付页显示"往 address 付**精确的** expect_amount"(可复制),轮询 `GET /order/{id}` 直到 `paid`。
|
||||
用户付错金额 → 该到账进 orphan,需人工对账。
|
||||
|
||||
配置:`PAY_RECEIVE_ADDRESS`(单收款地址)或 `PAY_ACCOUNT_XPUB`(自动派生 index 0)。
|
||||
|
||||
## Phase D —— 归集(气隙签名,`cmd/sweep`)
|
||||
|
||||
把散在各收款地址的 USDT 扫到冷钱包,**助记词只在离线机上出现**,联网机永远拿不到私钥。
|
||||
四段式,跨气隙用文件传递(unsigned.json / signed.json):
|
||||
|
||||
```bash
|
||||
# ① 联网:列出有余额的收款地址
|
||||
PAY_ACCOUNT_XPUB=xpub... TRONGRID_API_KEY=... go run ./cmd/sweep plan --max 50
|
||||
|
||||
# ② 联网:构造未签名转账(全额 → 冷钱包),不碰私钥
|
||||
PAY_ACCOUNT_XPUB=xpub... TRONGRID_API_KEY=... \
|
||||
go run ./cmd/sweep build --cold TColdAddr... --max 50 --fee-limit 30000000 > unsigned.json
|
||||
|
||||
# ③ 离线(断网机):从 Bitwarden 取助记词进环境,独立验签后签名
|
||||
PAY_SWEEP_MNEMONIC="word1 ... word24" \
|
||||
go run ./cmd/sweep sign --cold TColdAddr... < unsigned.json > signed.json
|
||||
|
||||
# ④ 联网:广播
|
||||
TRONGRID_API_KEY=... go run ./cmd/sweep broadcast < signed.json
|
||||
```
|
||||
|
||||
**`sign` 独立验签(气隙安全的关键)**——对每笔交易:① 重算 txid=sha256(raw_data) 必须等于声称值
|
||||
(防 raw_data 被篡改);② 收款人+金额的 ABI 参数必须内嵌在 raw_data(防换收款人/改额);③ USDT 合约
|
||||
必须内嵌(防换币);④ 由助记词派生的地址必须等于 owner(防错钥匙)。任一不符即拒签。
|
||||
|
||||
**gas**:TRC20 转账要 energy,收款地址身上没 TRX——归集前先给这些地址垫少量 TRX(gas 钱包),
|
||||
或用能量租赁。(垫 gas 的辅助后续加;当前 `build` 已设 `--fee-limit`。)
|
||||
|
||||
> ⚠️ 联网上链部分(`build`/余额/`broadcast`)只在真链(Phase E)验证;纯 crypto
|
||||
> (地址解码/ABI/txid/签名可恢复)已单测。首次务必**小额**实跑一遍再放量。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
- `wallet`:派生一致性(xpub 路径 == 私钥路径)+ 金标准向量(需 A.4 核对)。
|
||||
- `store`/`pay`/`watcher`/`httpapi`:建单/派生地址/侦测到账/幂等/超时/HTTP。
|
||||
@@ -0,0 +1,131 @@
|
||||
// Command paywatch is the pangolin-pay service: it hands out per-order TRON
|
||||
// receiving addresses (watch-only, derived from an account xpub), watches
|
||||
// TronGrid for confirmed USDT payments, and marks orders paid. It holds NO
|
||||
// private keys — sweeping funds to cold storage is a separate offline step.
|
||||
//
|
||||
// Env:
|
||||
//
|
||||
// PAY_ACCOUNT_XPUB (required) watch-only account xpub, m/44'/195'/0'
|
||||
// PAY_DB SQLite path (default pay.db)
|
||||
// PAY_ADDR HTTP listen addr (default :8090)
|
||||
// PAY_POLL_SECONDS watcher poll interval (default 20)
|
||||
// TRONGRID_BASE TronGrid base URL (default https://api.trongrid.io)
|
||||
// TRONGRID_API_KEY TronGrid API key (recommended)
|
||||
// USDT_CONTRACT TRC20 USDT contract (default mainnet)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/httpapi"
|
||||
"github.com/wangjia/pangolin/pay/internal/pay"
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
"github.com/wangjia/pangolin/pay/internal/tron"
|
||||
"github.com/wangjia/pangolin/pay/internal/wallet"
|
||||
"github.com/wangjia/pangolin/pay/internal/watcher"
|
||||
)
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// selfcheck prints the first N receiving addresses derived from PAY_ACCOUNT_XPUB.
|
||||
// MONEY-CRITICAL: compare these against your own wallet app / iancoleman.io for
|
||||
// the SAME account xpub. If they don't match, the service would hand buyers
|
||||
// addresses you cannot spend — do NOT accept any payment until they match.
|
||||
func selfcheck() {
|
||||
xpub := os.Getenv("PAY_ACCOUNT_XPUB")
|
||||
if xpub == "" {
|
||||
fmt.Fprintln(os.Stderr, "PAY_ACCOUNT_XPUB is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
n := 5
|
||||
if len(os.Args) > 2 {
|
||||
if v, err := strconv.Atoi(os.Args[2]); err == nil && v > 0 {
|
||||
n = v
|
||||
}
|
||||
}
|
||||
fmt.Println("Derived receiving addresses (compare against your wallet for this xpub):")
|
||||
for i := 0; i < n; i++ {
|
||||
addr, err := wallet.AddressFromAccountXpub(xpub, 0, uint32(i))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "derive %d: %v\n", i, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf(" m/44'/195'/0'/0/%d -> %s\n", i, addr)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "selfcheck" {
|
||||
selfcheck()
|
||||
return
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
// Single fixed receiving address: either given directly, or derived as index 0
|
||||
// of the watch-only account xpub (= wallet A address 0).
|
||||
receiveAddr := os.Getenv("PAY_RECEIVE_ADDRESS")
|
||||
if receiveAddr == "" {
|
||||
xpub := os.Getenv("PAY_ACCOUNT_XPUB")
|
||||
if xpub == "" {
|
||||
log.Error("set PAY_RECEIVE_ADDRESS, or PAY_ACCOUNT_XPUB to derive address 0")
|
||||
os.Exit(1)
|
||||
}
|
||||
a, err := wallet.AddressFromAccountXpub(xpub, 0, 0)
|
||||
if err != nil {
|
||||
log.Error("derive receive address from xpub", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
receiveAddr = a
|
||||
}
|
||||
dbPath := env("PAY_DB", "pay.db")
|
||||
addr := env("PAY_ADDR", ":8090")
|
||||
pollSec, _ := strconv.Atoi(env("PAY_POLL_SECONDS", "20"))
|
||||
if pollSec <= 0 {
|
||||
pollSec = 20
|
||||
}
|
||||
|
||||
st, err := store.Open("file:" + dbPath + "?_txlock=immediate")
|
||||
if err != nil {
|
||||
log.Error("open store", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = st.Close() }()
|
||||
|
||||
svc := pay.New(st, pay.Config{ReceiveAddress: receiveAddr, OrderTTL: 15 * time.Minute})
|
||||
fetcher := tron.NewClient(env("TRONGRID_BASE", ""), env("USDT_CONTRACT", ""), os.Getenv("TRONGRID_API_KEY"))
|
||||
w := watcher.New(st, fetcher, receiveAddr, log)
|
||||
log.Info("receiving address", "address", receiveAddr)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go w.Loop(ctx, time.Duration(pollSec)*time.Second)
|
||||
|
||||
srv := &http.Server{Addr: addr, Handler: httpapi.New(svc), ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() {
|
||||
log.Info("pangolin-pay listening", "addr", addr, "poll_seconds", pollSec)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Error("http server", "err", err)
|
||||
stop()
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
sc, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(sc)
|
||||
log.Info("pangolin-pay stopped")
|
||||
}
|
||||
@@ -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,91 @@
|
||||
// Package httpapi exposes the order endpoints a storefront (e.g. 独角数卡) calls:
|
||||
// create a payment (get a receiving address) and poll its status.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/pay"
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
)
|
||||
|
||||
type Handler struct{ svc *pay.Service }
|
||||
|
||||
// New wires the routes (Go 1.22 method+wildcard patterns).
|
||||
func New(svc *pay.Service) http.Handler {
|
||||
h := &Handler{svc: svc}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /order", h.createOrder)
|
||||
mux.HandleFunc("GET /order/{orderNo}", h.getOrder)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
type createReq struct {
|
||||
UserRef string `json:"user_ref"`
|
||||
SKU string `json:"sku"`
|
||||
Amount int64 `json:"amount"` // base price, micro-USDT (1e-6)
|
||||
}
|
||||
|
||||
type orderResp struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
Address string `json:"address"`
|
||||
ExpectAmount int64 `json:"expect_amount"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
TxID string `json:"tx_id,omitempty"`
|
||||
}
|
||||
|
||||
func toResp(o *store.Order) orderResp {
|
||||
return orderResp{
|
||||
OrderNo: o.OrderNo,
|
||||
Address: o.Address,
|
||||
ExpectAmount: o.ExpectAmount,
|
||||
Status: string(o.Status),
|
||||
ExpiresAt: o.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
TxID: o.TxID,
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func (h *Handler) createOrder(w http.ResponseWriter, r *http.Request) {
|
||||
var req createReq
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
o, err := h.svc.CreateOrder(r.Context(), req.UserRef, req.SKU, req.Amount)
|
||||
if errors.Is(err, pay.ErrUserHasActiveOrder) {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "user already has an active order"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toResp(o))
|
||||
}
|
||||
|
||||
func (h *Handler) getOrder(w http.ResponseWriter, r *http.Request) {
|
||||
o, err := h.svc.GetOrder(r.Context(), r.PathValue("orderNo"))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toResp(o))
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/pay"
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
)
|
||||
|
||||
const recvAddr = "TRecv00000000000000000000000000000A"
|
||||
|
||||
func TestCreateGetAndConflict(t *testing.T) {
|
||||
st, _ := store.Open(":memory:")
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
srv := httptest.NewServer(New(pay.New(st, pay.Config{ReceiveAddress: recvAddr})))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"user_ref": "u1", "sku": "pro-year", "amount": 5_000000})
|
||||
resp, err := http.Post(srv.URL+"/order", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create status %d", resp.StatusCode)
|
||||
}
|
||||
var created orderResp
|
||||
_ = json.NewDecoder(resp.Body).Decode(&created)
|
||||
_ = resp.Body.Close()
|
||||
if created.Address != recvAddr || created.Status != "pending" {
|
||||
t.Fatalf("create resp: %+v", created)
|
||||
}
|
||||
if created.ExpectAmount <= 5_000000 {
|
||||
t.Fatalf("expect_amount %d should be base+unique tail", created.ExpectAmount)
|
||||
}
|
||||
|
||||
r2, _ := http.Get(srv.URL + "/order/" + created.OrderNo)
|
||||
if r2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get status %d", r2.StatusCode)
|
||||
}
|
||||
_ = r2.Body.Close()
|
||||
|
||||
// Same user again -> 409 Conflict.
|
||||
r3, _ := http.Post(srv.URL+"/order", "application/json", bytes.NewReader(body))
|
||||
if r3.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("want 409 for second active order, got %d", r3.StatusCode)
|
||||
}
|
||||
_ = r3.Body.Close()
|
||||
|
||||
r4, _ := http.Get(srv.URL + "/order/NOPE")
|
||||
if r4.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", r4.StatusCode)
|
||||
}
|
||||
_ = r4.Body.Close()
|
||||
|
||||
r5, _ := http.Post(srv.URL+"/order", "application/json", bytes.NewReader([]byte(`{"user_ref":"u2","sku":"x","amount":0}`)))
|
||||
if r5.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 for bad amount, got %d", r5.StatusCode)
|
||||
}
|
||||
_ = r5.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Package pay is the order service: create a payment (single fixed receiving
|
||||
// address + a unique amount) and look one up. Orders are distinguished by the
|
||||
// unique amount, so one receiving address serves all of them.
|
||||
package pay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ReceiveAddress string // the single fixed receiving address (wallet A addr 0)
|
||||
OrderTTL time.Duration // how long a pending order stays payable (default 15m)
|
||||
TailMax int64 // unique-amount tail range [1,TailMax] micro-USDT (default 9999, <0.01 USDT)
|
||||
AmountCooldown time.Duration // an amount stays reserved this long against reuse; must exceed OrderTTL (default 30m)
|
||||
}
|
||||
|
||||
// ErrUserHasActiveOrder is returned when a user already has a pending order.
|
||||
var ErrUserHasActiveOrder = errors.New("pay: user already has an active order")
|
||||
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
cfg Config
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(st *store.Store, cfg Config) *Service {
|
||||
if cfg.OrderTTL <= 0 {
|
||||
cfg.OrderTTL = 15 * time.Minute
|
||||
}
|
||||
if cfg.TailMax <= 0 {
|
||||
cfg.TailMax = 9999
|
||||
}
|
||||
if cfg.AmountCooldown <= 0 {
|
||||
cfg.AmountCooldown = 30 * time.Minute
|
||||
}
|
||||
return &Service{st: st, cfg: cfg, now: time.Now}
|
||||
}
|
||||
|
||||
// CreateOrder records a pending order for userRef at base price priceMicro
|
||||
// (micro-USDT), assigning a unique amount (base + tail) that no recent order
|
||||
// shares — so a stale payment can never match a new order. One active order per
|
||||
// user is enforced.
|
||||
func (s *Service) CreateOrder(ctx context.Context, userRef, sku string, priceMicro int64) (*store.Order, error) {
|
||||
if userRef == "" {
|
||||
return nil, fmt.Errorf("pay: userRef required")
|
||||
}
|
||||
if sku == "" {
|
||||
return nil, fmt.Errorf("pay: sku required")
|
||||
}
|
||||
if priceMicro <= 0 {
|
||||
return nil, fmt.Errorf("pay: price must be positive")
|
||||
}
|
||||
if s.cfg.ReceiveAddress == "" {
|
||||
return nil, fmt.Errorf("pay: receive address not configured")
|
||||
}
|
||||
|
||||
// One active order per user.
|
||||
if _, err := s.st.ActiveOrderByUser(ctx, userRef); err == nil {
|
||||
return nil, ErrUserHasActiveOrder
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
return nil, fmt.Errorf("pay: check active order: %w", err)
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
amount, err := s.allocateAmount(ctx, priceMicro, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
o := &store.Order{
|
||||
OrderNo: newOrderNo(now),
|
||||
UserRef: userRef,
|
||||
SKU: sku,
|
||||
ExpectAmount: amount,
|
||||
Address: s.cfg.ReceiveAddress,
|
||||
Status: store.StatusPending,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(s.cfg.OrderTTL),
|
||||
}
|
||||
if err := s.st.CreateOrder(ctx, o); err != nil {
|
||||
return nil, fmt.Errorf("pay: create order: %w", err)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// allocateAmount picks base+tail such that the amount wasn't used within the
|
||||
// cooldown window (keeps concurrent + recently-expired amounts distinct).
|
||||
func (s *Service) allocateAmount(ctx context.Context, base int64, now time.Time) (int64, error) {
|
||||
since := now.Add(-s.cfg.AmountCooldown).Unix()
|
||||
for attempt := 0; attempt < 64; attempt++ {
|
||||
tail, err := randInt(s.cfg.TailMax) // [1, TailMax]
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
amount := base + tail
|
||||
used, err := s.st.AmountRecentlyUsed(ctx, amount, since)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("pay: amount check: %w", err)
|
||||
}
|
||||
if !used {
|
||||
return amount, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("pay: could not allocate a unique amount (too many concurrent orders at this price?)")
|
||||
}
|
||||
|
||||
func (s *Service) GetOrder(ctx context.Context, orderNo string) (*store.Order, error) {
|
||||
return s.st.GetOrder(ctx, orderNo)
|
||||
}
|
||||
|
||||
func newOrderNo(t time.Time) string {
|
||||
var b [6]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return fmt.Sprintf("PAY%s%x", t.UTC().Format("20060102150405"), b)
|
||||
}
|
||||
|
||||
// randInt returns a uniform integer in [1, max].
|
||||
func randInt(max int64) (int64, error) {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(max))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n.Int64() + 1, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package pay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
)
|
||||
|
||||
const recvAddr = "TRecv00000000000000000000000000000A"
|
||||
|
||||
func newSvc(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
st, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return New(st, Config{ReceiveAddress: recvAddr})
|
||||
}
|
||||
|
||||
func TestCreateOrderUniqueAmountSameAddress(t *testing.T) {
|
||||
svc := newSvc(t)
|
||||
ctx := context.Background()
|
||||
|
||||
o1, err := svc.CreateOrder(ctx, "u1", "pro-year", 5_000000)
|
||||
if err != nil {
|
||||
t.Fatalf("order1: %v", err)
|
||||
}
|
||||
if o1.Address != recvAddr {
|
||||
t.Fatalf("addr %s, want single receive address", o1.Address)
|
||||
}
|
||||
if o1.ExpectAmount <= 5_000000 || o1.ExpectAmount > 5_000000+9999 {
|
||||
t.Fatalf("amount %d not base+tail(<=9999)", o1.ExpectAmount)
|
||||
}
|
||||
|
||||
o2, err := svc.CreateOrder(ctx, "u2", "pro-year", 5_000000)
|
||||
if err != nil {
|
||||
t.Fatalf("order2: %v", err)
|
||||
}
|
||||
if o2.ExpectAmount == o1.ExpectAmount {
|
||||
t.Fatal("amounts must be unique across concurrent orders")
|
||||
}
|
||||
if o2.Address != o1.Address {
|
||||
t.Fatal("single-address model: both orders share the receiving address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrderOneActivePerUser(t *testing.T) {
|
||||
svc := newSvc(t)
|
||||
ctx := context.Background()
|
||||
if _, err := svc.CreateOrder(ctx, "u1", "pro", 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := svc.CreateOrder(ctx, "u1", "pro", 100)
|
||||
if !errors.Is(err, ErrUserHasActiveOrder) {
|
||||
t.Fatalf("want ErrUserHasActiveOrder, got %v", err)
|
||||
}
|
||||
if _, err := svc.CreateOrder(ctx, "u2", "pro", 100); err != nil {
|
||||
t.Fatalf("different user should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrderRejectsBadInput(t *testing.T) {
|
||||
svc := newSvc(t)
|
||||
ctx := context.Background()
|
||||
if _, err := svc.CreateOrder(ctx, "", "pro", 100); err == nil {
|
||||
t.Fatal("empty userRef should error")
|
||||
}
|
||||
if _, err := svc.CreateOrder(ctx, "u1", "", 100); err == nil {
|
||||
t.Fatal("empty sku should error")
|
||||
}
|
||||
if _, err := svc.CreateOrder(ctx, "u1", "pro", 0); err == nil {
|
||||
t.Fatal("non-positive price should error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Package store persists pay orders + orphan payments in SQLite (pure-Go
|
||||
// modernc driver, no CGO — same choice as the control plane).
|
||||
//
|
||||
// Model: single fixed receiving address + a unique amount per order. Orders are
|
||||
// matched by (amount == expect_amount) and (payment block time > order created),
|
||||
// so a payment can never be misattributed to a later order that happens to share
|
||||
// the same address.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusPaid Status = "paid"
|
||||
StatusExpired Status = "expired"
|
||||
)
|
||||
|
||||
// Order is one payment request. Amounts are micro-USDT (1e-6), matching the raw
|
||||
// integer value of a TRC20 USDT transfer (USDT has 6 decimals). ExpectAmount is
|
||||
// the *unique* amount (base price + a small unique tail).
|
||||
type Order struct {
|
||||
OrderNo string
|
||||
UserRef string
|
||||
SKU string
|
||||
ExpectAmount int64
|
||||
Address string
|
||||
Status Status
|
||||
TxID string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var ErrNotFound = errors.New("store: order not found")
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
func Open(dsn string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1) // SQLite: serialize writers
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS pay_orders(
|
||||
order_no TEXT PRIMARY KEY,
|
||||
user_ref TEXT NOT NULL,
|
||||
sku TEXT NOT NULL,
|
||||
expect_amount INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
tx_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_orders_status ON pay_orders(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_orders_amount_created ON pay_orders(expect_amount, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_orders_user_status ON pay_orders(user_ref, status)`,
|
||||
`CREATE TABLE IF NOT EXISTS orphan_payments(
|
||||
tx_id TEXT PRIMARY KEY,
|
||||
address TEXT NOT NULL,
|
||||
value INTEGER NOT NULL,
|
||||
block_ts INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
handled INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateOrder(ctx context.Context, o *Order) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO pay_orders(order_no,user_ref,sku,expect_amount,address,status,created_at,expires_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`,
|
||||
o.OrderNo, o.UserRef, o.SKU, o.ExpectAmount, o.Address, o.Status, o.CreatedAt.Unix(), o.ExpiresAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
const cols = `order_no,user_ref,sku,expect_amount,address,status,tx_id,created_at,expires_at`
|
||||
|
||||
func scanOrder(sc interface{ Scan(...any) error }) (*Order, error) {
|
||||
o := &Order{}
|
||||
var created, expires int64
|
||||
if err := sc.Scan(&o.OrderNo, &o.UserRef, &o.SKU, &o.ExpectAmount, &o.Address, &o.Status, &o.TxID, &created, &expires); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.CreatedAt = time.Unix(created, 0)
|
||||
o.ExpiresAt = time.Unix(expires, 0)
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOrder(ctx context.Context, orderNo string) (*Order, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+cols+` FROM pay_orders WHERE order_no=?`, orderNo)
|
||||
o, err := scanOrder(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return o, err
|
||||
}
|
||||
|
||||
func (s *Store) ListPending(ctx context.Context) ([]*Order, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+cols+` FROM pay_orders WHERE status=?`, StatusPending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []*Order
|
||||
for rows.Next() {
|
||||
o, err := scanOrder(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ActiveOrderByUser returns the user's pending order, or (nil, ErrNotFound) if
|
||||
// none — used to enforce "one active order per user".
|
||||
func (s *Store) ActiveOrderByUser(ctx context.Context, userRef string) (*Order, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+cols+` FROM pay_orders WHERE user_ref=? AND status=? LIMIT 1`, userRef, StatusPending)
|
||||
o, err := scanOrder(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return o, err
|
||||
}
|
||||
|
||||
// AmountRecentlyUsed reports whether any order with this expect_amount was
|
||||
// created at/after sinceUnix — used to keep the unique amount collision-free
|
||||
// within the late-payment window (so a stale payment can't match a new order).
|
||||
func (s *Store) AmountRecentlyUsed(ctx context.Context, amount, sinceUnix int64) (bool, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM pay_orders WHERE expect_amount=? AND created_at>=?`, amount, sinceUnix).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// MarkPaid transitions pending->paid idempotently (only affects a still-pending
|
||||
// row). Returns true if this call flipped it.
|
||||
func (s *Store) MarkPaid(ctx context.Context, orderNo, txID string) (bool, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE pay_orders SET status=?, tx_id=? WHERE order_no=? AND status=?`,
|
||||
StatusPaid, txID, orderNo, StatusPending)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// MarkExpired flips pending->expired for orders past their deadline.
|
||||
func (s *Store) MarkExpired(ctx context.Context, now time.Time) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE pay_orders SET status=? WHERE status=? AND expires_at < ?`,
|
||||
StatusExpired, StatusPending, now.Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// TxHandled reports whether a tx id has already been consumed — either matched
|
||||
// to an order (pay_orders.tx_id) or recorded as an orphan. Guards idempotency.
|
||||
func (s *Store) TxHandled(ctx context.Context, txID string) (bool, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT (SELECT COUNT(*) FROM pay_orders WHERE tx_id=?) + (SELECT COUNT(*) FROM orphan_payments WHERE tx_id=?)`,
|
||||
txID, txID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// RecordOrphan stores a payment that matched no active order (wrong amount / late
|
||||
// after the address was reused). Idempotent on tx_id. Needs manual reconciliation.
|
||||
func (s *Store) RecordOrphan(ctx context.Context, txID, address string, value, blockTs int64, now time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO orphan_payments(tx_id,address,value,block_ts,created_at) VALUES(?,?,?,?,?)`,
|
||||
txID, address, value, blockTs, now.Unix())
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func openMem(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func mkOrder(no, user string, amount int64, addr string, now time.Time) *Order {
|
||||
return &Order{
|
||||
OrderNo: no, UserRef: user, SKU: "pro", ExpectAmount: amount, Address: addr,
|
||||
Status: StatusPending, CreatedAt: now, ExpiresAt: now.Add(15 * time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderRoundtripAndMarkPaidIdempotent(t *testing.T) {
|
||||
s := openMem(t)
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
if err := s.CreateOrder(ctx, mkOrder("PAY1", "u1", 5_000017, "TADDR", now)); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
got, err := s.GetOrder(ctx, "PAY1")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.UserRef != "u1" || got.ExpectAmount != 5_000017 || got.Status != StatusPending {
|
||||
t.Fatalf("roundtrip: %+v", got)
|
||||
}
|
||||
ok, err := s.MarkPaid(ctx, "PAY1", "tx-a")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("first MarkPaid ok=%v err=%v", ok, err)
|
||||
}
|
||||
ok2, err := s.MarkPaid(ctx, "PAY1", "tx-b")
|
||||
if err != nil || ok2 {
|
||||
t.Fatalf("second MarkPaid ok=%v err=%v (want false)", ok2, err)
|
||||
}
|
||||
got, _ = s.GetOrder(ctx, "PAY1")
|
||||
if got.Status != StatusPaid || got.TxID != "tx-a" {
|
||||
t.Fatalf("after paid: %s / %s", got.Status, got.TxID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveOrderByUser(t *testing.T) {
|
||||
s := openMem(t)
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
_ = s.CreateOrder(ctx, mkOrder("PAY1", "u1", 100, "T", now))
|
||||
|
||||
o, err := s.ActiveOrderByUser(ctx, "u1")
|
||||
if err != nil || o.OrderNo != "PAY1" {
|
||||
t.Fatalf("u1 active: %v %v", o, err)
|
||||
}
|
||||
if _, err := s.ActiveOrderByUser(ctx, "u2"); err != ErrNotFound {
|
||||
t.Fatalf("u2 want ErrNotFound, got %v", err)
|
||||
}
|
||||
_, _ = s.MarkPaid(ctx, "PAY1", "tx")
|
||||
if _, err := s.ActiveOrderByUser(ctx, "u1"); err != ErrNotFound {
|
||||
t.Fatalf("paid should not be active: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmountRecentlyUsed(t *testing.T) {
|
||||
s := openMem(t)
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
_ = s.CreateOrder(ctx, mkOrder("PAY1", "u1", 5_000017, "T", now))
|
||||
since := now.Add(-30 * time.Minute).Unix()
|
||||
|
||||
if used, _ := s.AmountRecentlyUsed(ctx, 5_000017, since); !used {
|
||||
t.Fatal("5_000017 should be recently used")
|
||||
}
|
||||
if used, _ := s.AmountRecentlyUsed(ctx, 5_000018, since); used {
|
||||
t.Fatal("5_000018 not used")
|
||||
}
|
||||
if used, _ := s.AmountRecentlyUsed(ctx, 5_000017, now.Add(time.Minute).Unix()); used {
|
||||
t.Fatal("outside window should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTxHandledAndOrphan(t *testing.T) {
|
||||
s := openMem(t)
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
_ = s.CreateOrder(ctx, mkOrder("PAY1", "u1", 100, "T", now))
|
||||
|
||||
if h, _ := s.TxHandled(ctx, "tx-x"); h {
|
||||
t.Fatal("tx-x should be unhandled")
|
||||
}
|
||||
_, _ = s.MarkPaid(ctx, "PAY1", "tx-x")
|
||||
if h, _ := s.TxHandled(ctx, "tx-x"); !h {
|
||||
t.Fatal("matched tx should be handled")
|
||||
}
|
||||
if err := s.RecordOrphan(ctx, "tx-o", "T", 999, now.Unix(), now); err != nil {
|
||||
t.Fatalf("orphan: %v", err)
|
||||
}
|
||||
if h, _ := s.TxHandled(ctx, "tx-o"); !h {
|
||||
t.Fatal("orphan tx should be handled")
|
||||
}
|
||||
if err := s.RecordOrphan(ctx, "tx-o", "T", 999, now.Unix(), now); err != nil {
|
||||
t.Fatalf("orphan idempotent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkExpired(t *testing.T) {
|
||||
s := openMem(t)
|
||||
ctx := context.Background()
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
_ = s.CreateOrder(ctx, &Order{OrderNo: "old", UserRef: "u1", SKU: "x", ExpectAmount: 1, Address: "T", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(time.Minute)})
|
||||
_ = s.CreateOrder(ctx, &Order{OrderNo: "new", UserRef: "u2", SKU: "x", ExpectAmount: 2, Address: "T", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(time.Hour)})
|
||||
|
||||
n, err := s.MarkExpired(ctx, base.Add(10*time.Minute))
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("MarkExpired n=%d err=%v", n, err)
|
||||
}
|
||||
o1, _ := s.GetOrder(ctx, "old")
|
||||
o2, _ := s.GetOrder(ctx, "new")
|
||||
if o1.Status != StatusExpired || o2.Status != StatusPending {
|
||||
t.Fatalf("old=%s new=%s", o1.Status, o2.Status)
|
||||
}
|
||||
}
|
||||
@@ -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,95 @@
|
||||
// Package tron reads confirmed incoming TRC20 (USDT) transfers from TronGrid.
|
||||
// Only reads — the watcher never signs or moves funds (that's offline sweeping).
|
||||
package tron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// USDTContractMainnet is the TRON mainnet USDT (TRC20) contract. 6 decimals.
|
||||
// ⚠️ Verify before relying on it in production (Phase-level constant check).
|
||||
const USDTContractMainnet = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||
|
||||
// Transfer is one confirmed incoming TRC20 transfer to a watched address.
|
||||
// Value is the raw integer amount (micro-USDT, since USDT has 6 decimals).
|
||||
// BlockTs is the on-chain block time in **unix seconds** — used to reject a
|
||||
// payment that arrived before the order it might match was created.
|
||||
type Transfer struct {
|
||||
TxID string
|
||||
To string
|
||||
Value int64
|
||||
BlockTs int64
|
||||
}
|
||||
|
||||
// Fetcher returns confirmed incoming USDT transfers to a given address.
|
||||
type Fetcher interface {
|
||||
IncomingTransfers(ctx context.Context, address string) ([]Transfer, error)
|
||||
}
|
||||
|
||||
// Client talks to the TronGrid HTTP API.
|
||||
type Client struct {
|
||||
base string
|
||||
usdtContract string
|
||||
apiKey string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func NewClient(base, usdtContract, apiKey string) *Client {
|
||||
if base == "" {
|
||||
base = "https://api.trongrid.io"
|
||||
}
|
||||
if usdtContract == "" {
|
||||
usdtContract = USDTContractMainnet
|
||||
}
|
||||
return &Client{base: base, usdtContract: usdtContract, apiKey: apiKey, hc: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
func (c *Client) IncomingTransfers(ctx context.Context, address string) ([]Transfer, error) {
|
||||
u := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20?only_confirmed=true&contract_address=%s&limit=50",
|
||||
c.base, url.PathEscape(address), url.QueryEscape(c.usdtContract))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("tron: trongrid status %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data []struct {
|
||||
TransactionID string `json:"transaction_id"`
|
||||
To string `json:"to"`
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
BlockTimestamp int64 `json:"block_timestamp"` // milliseconds
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("tron: decode: %w", err)
|
||||
}
|
||||
out := make([]Transfer, 0, len(body.Data))
|
||||
for _, d := range body.Data {
|
||||
if d.To != address || d.Type != "Transfer" {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseInt(d.Value, 10, 64)
|
||||
if err != nil {
|
||||
continue // skip malformed value rather than fail the whole batch
|
||||
}
|
||||
out = append(out, Transfer{TxID: d.TransactionID, To: d.To, Value: v, BlockTs: d.BlockTimestamp / 1000})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -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,112 @@
|
||||
// Package watcher polls TronGrid for incoming USDT to the single receiving
|
||||
// address and matches each confirmed payment to a pending order by exact amount
|
||||
// + block time. It only reads the chain and flips order state — it never holds
|
||||
// keys or moves funds (sweeping is a separate offline step).
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
"github.com/wangjia/pangolin/pay/internal/tron"
|
||||
)
|
||||
|
||||
type Watcher struct {
|
||||
st *store.Store
|
||||
tron tron.Fetcher
|
||||
address string
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(st *store.Store, f tron.Fetcher, address string, log *slog.Logger) *Watcher {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Watcher{st: st, tron: f, address: address, log: log, now: time.Now}
|
||||
}
|
||||
|
||||
// Tick:
|
||||
// 1. expire overdue pending orders;
|
||||
// 2. fetch confirmed incoming USDT transfers to the single receiving address;
|
||||
// 3. match each transfer to a pending order by **exact amount** and **block time
|
||||
// after the order was created**; a confirmed transfer that matches no active
|
||||
// order (wrong amount / late after reuse) is recorded as an orphan.
|
||||
//
|
||||
// Idempotent: a tx already matched to an order or recorded as orphan is skipped.
|
||||
func (w *Watcher) Tick(ctx context.Context) error {
|
||||
if n, err := w.st.MarkExpired(ctx, w.now()); err != nil {
|
||||
return err
|
||||
} else if n > 0 {
|
||||
w.log.Info("orders expired", "count", n)
|
||||
}
|
||||
|
||||
transfers, err := w.tron.IncomingTransfers(ctx, w.address)
|
||||
if err != nil {
|
||||
w.log.Warn("fetch transfers failed", "err", err)
|
||||
return nil // transient (rate limit / network); retried next tick
|
||||
}
|
||||
if len(transfers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pending, err := w.st.ListPending(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Index pending orders by their unique expect amount.
|
||||
byAmount := make(map[int64]*store.Order, len(pending))
|
||||
for _, o := range pending {
|
||||
byAmount[o.ExpectAmount] = o
|
||||
}
|
||||
|
||||
for _, t := range transfers {
|
||||
handled, err := w.st.TxHandled(ctx, t.TxID)
|
||||
if err != nil {
|
||||
w.log.Error("tx handled check", "tx", t.TxID, "err", err)
|
||||
continue
|
||||
}
|
||||
if handled {
|
||||
continue // already matched or already an orphan
|
||||
}
|
||||
|
||||
if o := byAmount[t.Value]; o != nil && t.BlockTs > o.CreatedAt.Unix() {
|
||||
ok, err := w.st.MarkPaid(ctx, o.OrderNo, t.TxID)
|
||||
if err != nil {
|
||||
w.log.Error("mark paid", "order", o.OrderNo, "err", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
w.log.Info("order paid", "order", o.OrderNo, "tx", t.TxID, "value", t.Value)
|
||||
delete(byAmount, t.Value) // a second transfer of the same amount can't reuse this order
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Confirmed payment matching no active order -> orphan (needs reconciliation).
|
||||
if err := w.st.RecordOrphan(ctx, t.TxID, w.address, t.Value, t.BlockTs, w.now()); err != nil {
|
||||
w.log.Error("record orphan", "tx", t.TxID, "err", err)
|
||||
continue
|
||||
}
|
||||
w.log.Warn("orphan payment", "tx", t.TxID, "value", t.Value, "block_ts", t.BlockTs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Loop runs Tick every interval until ctx is cancelled.
|
||||
func (w *Watcher) Loop(ctx context.Context, interval time.Duration) {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := w.Tick(ctx); err != nil {
|
||||
w.log.Error("watcher tick", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/store"
|
||||
"github.com/wangjia/pangolin/pay/internal/tron"
|
||||
)
|
||||
|
||||
const recvAddr = "TRecv00000000000000000000000000000A"
|
||||
|
||||
type mockFetcher struct{ transfers []tron.Transfer }
|
||||
|
||||
func (f *mockFetcher) IncomingTransfers(_ context.Context, _ string) ([]tron.Transfer, error) {
|
||||
return f.transfers, nil
|
||||
}
|
||||
|
||||
func memStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func seed(t *testing.T, st *store.Store, no string, amount int64, created time.Time) {
|
||||
t.Helper()
|
||||
o := &store.Order{
|
||||
OrderNo: no, UserRef: "u", SKU: "pro", ExpectAmount: amount, Address: recvAddr,
|
||||
Status: store.StatusPending, CreatedAt: created, ExpiresAt: created.Add(time.Hour),
|
||||
}
|
||||
if err := st.CreateOrder(context.Background(), o); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcherMatchesByAmountAndTime(t *testing.T) {
|
||||
st := memStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_100, 0)
|
||||
created := now.Add(-5 * time.Minute)
|
||||
seed(t, st, "PAY1", 5_000017, created)
|
||||
seed(t, st, "PAY2", 5_000018, created)
|
||||
|
||||
fetch := &mockFetcher{transfers: []tron.Transfer{
|
||||
{TxID: "tx1", To: recvAddr, Value: 5_000017, BlockTs: created.Add(time.Minute).Unix()},
|
||||
}}
|
||||
w := New(st, fetch, recvAddr, nil)
|
||||
w.now = func() time.Time { return now }
|
||||
|
||||
if err := w.Tick(ctx); err != nil {
|
||||
t.Fatalf("tick: %v", err)
|
||||
}
|
||||
o1, _ := st.GetOrder(ctx, "PAY1")
|
||||
if o1.Status != store.StatusPaid || o1.TxID != "tx1" {
|
||||
t.Fatalf("PAY1 %s/%s", o1.Status, o1.TxID)
|
||||
}
|
||||
o2, _ := st.GetOrder(ctx, "PAY2")
|
||||
if o2.Status != store.StatusPending {
|
||||
t.Fatalf("PAY2 should stay pending, got %s", o2.Status)
|
||||
}
|
||||
|
||||
if err := w.Tick(ctx); err != nil { // idempotent
|
||||
t.Fatalf("tick2: %v", err)
|
||||
}
|
||||
o1, _ = st.GetOrder(ctx, "PAY1")
|
||||
if o1.Status != store.StatusPaid || o1.TxID != "tx1" {
|
||||
t.Fatal("idempotency broken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcherWrongAmountIsOrphan(t *testing.T) {
|
||||
st := memStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_100, 0)
|
||||
created := now.Add(-5 * time.Minute)
|
||||
seed(t, st, "PAY1", 5_000017, created)
|
||||
|
||||
fetch := &mockFetcher{transfers: []tron.Transfer{
|
||||
{TxID: "tx-wrong", To: recvAddr, Value: 5_000000, BlockTs: created.Add(time.Minute).Unix()},
|
||||
}}
|
||||
w := New(st, fetch, recvAddr, nil)
|
||||
w.now = func() time.Time { return now }
|
||||
_ = w.Tick(ctx)
|
||||
|
||||
o, _ := st.GetOrder(ctx, "PAY1")
|
||||
if o.Status != store.StatusPending {
|
||||
t.Fatalf("PAY1 should stay pending, got %s", o.Status)
|
||||
}
|
||||
if h, _ := st.TxHandled(ctx, "tx-wrong"); !h {
|
||||
t.Fatal("wrong-amount payment should be recorded as orphan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcherLatePaymentDoesNotMatchNewOrder(t *testing.T) {
|
||||
// Order1 (amount 5_000017) expired; a NEW order (amount 5_000018) is now active
|
||||
// on the SAME address. A late payment of the OLD amount must NOT match the new
|
||||
// order (different amount) -> orphan.
|
||||
st := memStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_500, 0)
|
||||
seed(t, st, "PAY2", 5_000018, now.Add(-time.Minute))
|
||||
|
||||
fetch := &mockFetcher{transfers: []tron.Transfer{
|
||||
{TxID: "tx-late", To: recvAddr, Value: 5_000017, BlockTs: now.Unix()},
|
||||
}}
|
||||
w := New(st, fetch, recvAddr, nil)
|
||||
w.now = func() time.Time { return now }
|
||||
_ = w.Tick(ctx)
|
||||
|
||||
o2, _ := st.GetOrder(ctx, "PAY2")
|
||||
if o2.Status != store.StatusPending {
|
||||
t.Fatalf("PAY2 must not be matched by a wrong-amount late payment, got %s", o2.Status)
|
||||
}
|
||||
if h, _ := st.TxHandled(ctx, "tx-late"); !h {
|
||||
t.Fatal("late payment should be orphan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcherIgnoresPaymentBeforeOrder(t *testing.T) {
|
||||
// A payment whose block time is BEFORE the order was created must not match
|
||||
// (guards address reuse: prior balance / old tx).
|
||||
st := memStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_500, 0)
|
||||
created := now.Add(-2 * time.Minute)
|
||||
seed(t, st, "PAY1", 5_000017, created)
|
||||
|
||||
fetch := &mockFetcher{transfers: []tron.Transfer{
|
||||
{TxID: "tx-old", To: recvAddr, Value: 5_000017, BlockTs: created.Add(-time.Minute).Unix()},
|
||||
}}
|
||||
w := New(st, fetch, recvAddr, nil)
|
||||
w.now = func() time.Time { return now }
|
||||
_ = w.Tick(ctx)
|
||||
|
||||
o, _ := st.GetOrder(ctx, "PAY1")
|
||||
if o.Status != store.StatusPending {
|
||||
t.Fatalf("payment before order must not match, got %s", o.Status)
|
||||
}
|
||||
if h, _ := st.TxHandled(ctx, "tx-old"); !h {
|
||||
t.Fatal("pre-order payment should be orphan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcherExpires(t *testing.T) {
|
||||
st := memStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Unix(1_700_000_500, 0)
|
||||
o := &store.Order{
|
||||
OrderNo: "OLD", UserRef: "u", SKU: "pro", ExpectAmount: 1, Address: recvAddr,
|
||||
Status: store.StatusPending, CreatedAt: now.Add(-time.Hour), ExpiresAt: now.Add(-time.Minute),
|
||||
}
|
||||
_ = st.CreateOrder(ctx, o)
|
||||
|
||||
w := New(st, &mockFetcher{}, recvAddr, nil)
|
||||
w.now = func() time.Time { return now }
|
||||
_ = w.Tick(ctx)
|
||||
|
||||
got, _ := st.GetOrder(ctx, "OLD")
|
||||
if got.Status != store.StatusExpired {
|
||||
t.Fatalf("want expired, got %s", got.Status)
|
||||
}
|
||||
}
|
||||
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