Compare commits

...

2 Commits

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:06:59 +08:00
16 changed files with 682 additions and 425 deletions
+4 -4
View File
@@ -91,10 +91,10 @@
</a>
<h2>实现计划 / Plans</h2>
<a class="doc" href="pay-address-pool-plan.html">
<div class="t">pangolin-pay 地址收款模型(折中方案#34/34A<span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-09-pay-address-pool.md</code>(含 checkbox)。从"每单唯一 HD 地址"改为<b>地址池复用</b>:池大小=峰值并行度(初始 10,不够动态派生),地址复用摊薄激活/归集,归集成本 O(订单数)→O(池大小)。规则:最小编号 idle 绑单、无 idle 派生新址、15min 超时释放、同用户单订单。核心安全点:地址复用的<b>迟到付款错配</b>——匹配按"到账 tx+金额+时间戳(晚于建单)"、每单唯一金额、孤儿付款记录。5 Phase数据模型→建单→watcher 匹配/释放/孤儿→归集(能量租赁)→并发/复用真链验证。</div>
<div class="path">docs/pay-address-pool-plan.html · 真相源 docs/superpowers/plans/2026-07-09-pay-address-pool.md</div>
<a 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>
-110
View File
@@ -1,110 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pangolin-pay 地址池收款模型 实现计划</title>
<style>
:root{
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:28px;line-height:1.3;margin:0 0 8px}
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
h2{font-size:20px;margin:40px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border);color:var(--accent)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.85em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:12.5px;line-height:1.5;color:#cdd3df}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px}
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
ul.ck{list-style:none;padding-left:4px}
ul.ck li{position:relative;padding-left:26px;margin:7px 0}
ul.ck li::before{content:"☐";position:absolute;left:0;color:var(--fg2)}
ul{padding-left:22px} li{margin:6px 0}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.small{color:var(--fg2);font-size:13px}
a{color:var(--accent2)} .back{display:inline-block;margin-bottom:24px;font-size:13px}
b{color:#fff}
.warnbox{background:rgba(224,106,106,.08);border:1px solid rgba(224,106,106,.35);border-radius:12px;padding:14px 18px;margin:14px 0}
.warnbox b{color:var(--bad)}
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:13.5px}
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--fg2);font-weight:600;font-size:13px}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 文档索引</a>
<h1>pangolin-pay 地址池收款模型</h1>
<p class="sub">2026-07-09 · <span class="tag info">阅读版</span> · 执行真相源 <code>docs/superpowers/plans/2026-07-09-pay-address-pool.md</code> · #34/34A 收款模型修订(折中方案)</p>
<div class="lead">
从"每单唯一 HD 地址"改为<b>地址池复用</b>:池大小 = <b>峰值并行度</b>(初始 10,不够动态派生),地址复用摊薄激活/归集成本;仍多地址,<b>避免并发订单金额撞、对账精确</b>。归集成本从 O(订单数) 降到 <b>O(池大小)</b>,激活一次/地址即长期复用。是"单地址(省钱撞单)"与"每单唯一(精确但贵)"的折中。
</div>
<div class="warnbox">
<b>核心风险 · 地址复用的迟到付款错配:</b>地址回收后绑给新订单,若前一个用户超时后才付款,钱打到已绑新订单的地址上 → 可能误配。<b>解法(必须):</b>① 匹配按"到账 tx + 金额 + 时间戳(晚于建单)",<b>不按余额、不只按地址</b>;② <b>每单唯一金额</b>(基准+唯一尾数);③ 匹配不到活跃订单的到账 → 记 <code>orphan_payments</code> 人工对账;④(可选)超时后地址冷却再重绑。
</div>
<h2>设计(规则 + 安全点)</h2>
<ul>
<li><b>地址池</b>:HD 派生地址(<code>m/44'/195'/0'/0/i</code>),状态 <code>idle|busy</code>,初始 10。</li>
<li><b>建单</b>:绑<b>最小编号 idle</b> 地址→busy;无 idle → 派生新地址入池。<b>原子</b>(事务/行锁,防并发抢同址)。</li>
<li><b>每单唯一金额</b> ⭐:基准 + 唯一尾数,当前活跃订单间不撞;复用地址上旧单迟到付款不误配新单。</li>
<li><b>15min 超时</b> → expired → 地址回 idle(可选冷却)→ 提示重建订单。</li>
<li><b>付款完成</b> → paid → 地址回 idle(注意:地址里仍有 USDT 待归集,idle 只表示可重绑)。</li>
<li><b>匹配</b>:找"到该地址 + 金额命中 + 区块时间晚于建单"的转入 tx。</li>
<li><b>同用户单订单</b>:同时只能一个活跃订单。</li>
<li><b>归集</b>:定期扫池内有余额地址→冷钱包(能量租赁 ~$0.1–1/笔);池 ~10,笔数有界;与 busy 解耦。</li>
</ul>
<h2>Phase A — 数据模型</h2>
<ul class="ck">
<li><b>A.1</b> <code>addresses(id, addr_index唯一, address, state, activated, created_at)</code>,按需派生入池。</li>
<li><b>A.2</b> <code>pay_orders</code><code>user_ref / address_id / expect_amount(唯一金额) / matched_tx_id</code></li>
<li><b>A.3</b> <code>orphan_payments(tx_id唯一, address, value, block_ts, handled)</code></li>
<li><b>A.4</b> 复用 addr_cursor 派生新址;迁移 + 索引(status/address/user_ref)。</li>
</ul>
<h2>Phase B — 建单</h2>
<ul class="ck">
<li><b>B.1</b> CreateOrder(user, sku):校验同用户无活跃单 → 原子选最小 idle 置 busy(无则派生)→ 分配唯一金额 → 写 pending(TTL 15min)。</li>
<li><b>B.2</b> 唯一金额分配(基准+唯一尾数,当前活跃单间不撞)。</li>
<li><b>B.3</b> GetOrder + releaseAddress 公共方法(paid/expired 复用)。</li>
</ul>
<h2>Phase C — watcher(匹配/释放/孤儿)</h2>
<ul class="ck">
<li><b>C.1</b> 匹配:pending 单查其地址转入,value 命中唯一金额 且 <code>block_ts&gt;created</code> → paid + 记 tx + 释放地址。</li>
<li><b>C.2</b> 超时:pending 过期 → expired + 释放地址(可选冷却)。</li>
<li><b>C.3</b> 孤儿:匹配不到活跃订单的到账 → 记 orphan_payments(去重)。</li>
<li><b>C.4</b> 幂等(tx_id 去重)+ 崩溃恢复;TronGrid 取 block_timestamp 供时间过滤。</li>
</ul>
<h2>Phase D — 归集</h2>
<ul class="ck">
<li><b>D.1</b> 扫池内有余额地址 → 冷钱包(能量租赁,先手动 runbook);与订单状态解耦。</li>
</ul>
<h2>Phase E — 测试 + 真链</h2>
<ul class="ck">
<li><b>E.1</b> 单测:并发不抢同址、复用后迟到付款不误配、唯一金额、超时释放、孤儿、幂等、崩溃恢复、同用户单订单。</li>
<li><b>E.2</b> 真链:两并发订单各绑地址付款;一单超时释放后新单复用同址,旧地址补"旧金额"付款应成孤儿不误配;归集。</li>
</ul>
<h2>不在本轮 / 取舍</h2>
<ul>
<li>GasFree(#35,免 TRX 但每笔固定 1 USDT,更适合少地址批量,另议)。</li>
<li>能量租赁自动化(先手动);池增长上限(先宽松);发货侧(独角数卡 + /internal/codes/mint)另排。</li>
</ul>
<p class="small" style="margin-top:32px">相关:<a href="crypto-tx-engine-plan.html">加密货币交易引擎(已实现钱包/tron/sweep)</a> · <a href="payment-clean-usdt-plan.html">#34 收款闭环全景</a></p>
</div>
</body>
</html>
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pangolin-pay 单地址 + 唯一金额收款模型 实现计划</title>
<style>
:root{--bg:#0f1117;--panel:#171a22;--panel2:#1d2129;--fg:#e6e8ee;--fg2:#a8afbd;--accent:#e0884f;--accent2:#5fb0c9;--ok:#5ec27a;--bad:#e06a6a;--warn:#e0b84f;--border:#272c36;--mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:28px;line-height:1.3;margin:0 0 8px}
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
h2{font-size:20px;margin:40px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--border);color:var(--accent)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.85em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px}
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
ul.ck{list-style:none;padding-left:4px}
ul.ck li{position:relative;padding-left:26px;margin:7px 0}
ul.ck li::before{content:"☐";position:absolute;left:0;color:var(--fg2)}
ul{padding-left:22px} li{margin:6px 0}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.small{color:var(--fg2);font-size:13px}
a{color:var(--accent2)} .back{display:inline-block;margin-bottom:24px;font-size:13px} b{color:#fff}
.okbox{background:rgba(94,194,122,.07);border:1px solid rgba(94,194,122,.32);border-radius:12px;padding:14px 18px;margin:14px 0}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 文档索引</a>
<h1>pangolin-pay 单地址 + 唯一金额收款模型</h1>
<p class="sub">2026-07-09 · <span class="tag info">阅读版</span> · 执行真相源 <code>docs/superpowers/plans/2026-07-09-pay-single-address.md</code> · #34/34A 收款模型定稿</p>
<div class="lead">
<b>定稿:单个固定收款地址 + 每单唯一金额。</b> 归集 = 1 个地址(激活一次、扫一笔),成本最省;订单靠<b>金额</b>区分,不靠地址。前端契约不变(<code>POST /order</code> 返回 <code>address+expect_amount</code>)→ 以后升多地址/GasFree 是<b>纯后端换实现</b>,前端零改动。
</div>
<div class="okbox">
<b>唯一金额:</b><code>expect_amount = base + tail</code>,base=价格×1e6(micro-USDT),<b>tail∈[1,9999] micro</b>(偏差&lt;0.01 USDT,价格几乎不变),迟到窗口内不复用。<b>匹配=精确 ==</b>:找"到收款地址、value==expect_amount、block_ts&gt;建单"的转入 → paid。支付页显示<b>可复制的精确金额</b>。付错/迟到抹尾数 → 孤儿人工对账。
</div>
<h2>Phase A — 数据模型</h2>
<ul class="ck">
<li><b>A.1</b> pay_orders 加 user_ref / expect_amount(唯一金额)/ matched_tx_id;address 恒为收款地址。</li>
<li><b>A.2</b> tail 分配支撑:活跃订单已用 tail + 近期冷却(迟到窗口不复用)。</li>
<li><b>A.3</b> orphan_payments(tx_id唯一/value/block_ts/handled)。</li>
<li><b>A.4</b> 配置 PAY_RECEIVE_ADDRESS(或从 xpub 派生 index 0)。</li>
</ul>
<h2>Phase B — 建单</h2>
<ul class="ck">
<li><b>B.1</b> CreateOrder(user, sku, price):同用户无活跃单 → 分配唯一金额 → 写 pending(TTL 15min)。</li>
<li><b>B.2</b> tail 分配(活跃间不撞 + 迟到窗口不复用);GetOrder。</li>
</ul>
<h2>Phase C — watcher</h2>
<ul class="ck">
<li><b>C.1</b> 匹配:pending 单查收款地址转入,value==expect_amount 且 block_ts&gt;created → paid + tx。</li>
<li><b>C.2</b> 超时 → expired。</li>
<li><b>C.3</b> 孤儿:到账不匹配任何活跃订单 → orphan_payments(去重)。</li>
<li><b>C.4</b> 幂等 + 崩溃恢复;TronGrid 取 block_timestamp。</li>
</ul>
<h2>Phase D — 归集</h2>
<ul class="ck"><li><b>D.1</b> 扫收款地址余额 → 冷钱包(能量租赁);一地址一笔。</li></ul>
<h2>Phase E — 验证</h2>
<ul class="ck">
<li><b>E.1</b> 单测:唯一金额不撞、精确匹配、付错/迟到成孤儿、超时、同用户单订单、幂等、时间戳过滤。</li>
<li><b>E.2</b> 真链:两并发订单不同金额同地址各 paid;付错成孤儿;归集。</li>
</ul>
<h2>不在本轮</h2>
<ul><li>多地址/地址池、GasFree(#35)、能量租赁自动化、发货侧 —— 以后需要纯后端切,前端不动。</li></ul>
<p class="small" style="margin-top:32px">相关:<a href="crypto-tx-engine-plan.html">加密货币交易引擎(已实现钱包/tron/sweep)</a> · <a href="payment-channels-overview.html">渠道选型</a></p>
</div>
</body>
</html>
@@ -1,71 +0,0 @@
# pangolin-pay 地址池收款模型(折中方案,取代"每单唯一 HD 地址")
> #34/34A 收款模型修订。**Context**:每单唯一 HD 地址 → 每地址激活(~1 TRX)+ 归集(每笔一次转账)成本随
> **订单总数线性增长**(#34 暴露的严峻成本)。改为**地址池复用**:池大小 = **峰值并行度**(初始 10,不够动态派生),
> 地址**复用**摊薄激活/归集;仍是多地址,**避免并发订单金额撞、对账精确**。这是"单地址(省钱但撞单)"与
> "每单唯一(精确但贵)"的折中。
>
> 归集成本从"O(订单数)"降到"O(池大小)";激活成本一次性(每个池地址激活一次即长期复用)。
>
> **本 plan = 用户定的规则 + 补齐的安全点**(核心:地址复用的**迟到付款错配**)。已实现的 Phase B(钱包派生)、
> tron(TronGrid 读到账/建/广播/签名)、cmd/sweep 复用;主要改 store / pay(建单)/ watcher(匹配)。
## 设计(规则 + 安全点)
- **地址池**:HD 派生地址(路径 `m/44'/195'/0'/0/i`,与现有一致),各有状态 `idle | busy`。初始池 10。
- **建单**:绑**最小编号的 idle** 地址 → 置 `busy`;**无 idle → 派生下一个新地址**(编号递增)入池并绑之。
- **每单唯一金额** ⭐必需:基准价 + 唯一尾数(如按订单序号映射的微分),保证**当前活跃订单之间金额不撞**,
且复用地址上"旧订单迟到付款"不会误配"新订单"。
- **15 分钟超时**:超时 → `expired` → 地址回 `idle`(可选**冷却期**再重绑,降低迟到错配)→ 提示用户**重建订单**、用新订单地址付款。
- **付款完成**:watcher 确认到账 → 订单 `paid` → 地址回 `idle`。**注意**:此时地址里**仍有 USDT**(待归集);
`idle` 只表示"可重绑",不代表钱清了。
- **匹配规则(核心)**:watcher 找**到该地址、金额 == 该单唯一金额(或 ≥,带容差)、且到账区块时间晚于订单创建**
的 TRC20 转入 **tx** → 匹配。**绝不按余额、不只按地址**(地址会累积余额、会复用)。
- **同用户单订单**:同一用户同时只能有**一个**活跃(pending)订单。
- **孤儿付款**:到账 tx 匹配不到任何活跃订单(迟到 / 欠额 / 误差)→ 记 `orphan_payments`,人工对账/补发。
- **归集**:定期扫池内**有余额**的地址 → 冷钱包(**能量租赁** ~$0.11/笔);池 ~10 地址,**归集笔数有界**;
归集与 `busy` 无关(随时可扫,不影响待付订单——到账是未来的独立 tx)。
- **池增长上限**(可选):无空闲就新建,设上限防滥用无限增长(超限则排队/拒单)。
## Phase A — 数据模型改造
- [ ] `addresses` 表:`id / addr_index(唯一) / address / state('idle'|'busy') / activated(bool) / created_at`;初始不预建,按需派生入池。
- [ ] `pay_orders` 改:加 `user_ref`(同用户单订单约束用)、`address_id`(绑定的池地址)、`expect_amount`(唯一金额,micro-USDT)、`matched_tx_id`;保留 status/created_at/expires_at。
- [ ] `orphan_payments` 表:`tx_id(唯一) / address / value / block_ts / created_at / handled(bool)`
- [ ] 复用现有 `addr_cursor`(HD 派生游标)派生新池地址。迁移脚本 + 索引(status、address、user_ref)。
## Phase B — 建单(pay 服务)
- [ ] `CreateOrder(userRef, sku)`:① 校验**同用户无活跃订单**(有则返回现有/拒);② **原子**选最小编号 `idle` 地址(事务 + 行锁)置 `busy`;③ 无 idle → `NextAddrIndex` 派生新地址(`AddressFromAccountXpub`)入池并绑;④ 分配**唯一金额**;⑤ 写 `pending` 单(TTL 15min)。
- [ ] **唯一金额分配**:基准金额 + 唯一尾数,保证与**当前所有活跃订单**不撞(可用订单序号 → 尾数微分,micro-USDT 级);记录基准+尾数便于展示。
- [ ] `GetOrder`;释放地址的公共方法 `releaseAddress(addrID)`(paid/expired 复用)。
## Phase C — watcher 改造(匹配 + 释放 + 孤儿)
- [ ] **匹配**:对每个 `pending` 单,查其绑定地址的 TRC20 转入;筛 `value` 命中唯一金额(==,或 ≥ 带小容差)**且 `block_ts > order.created_at`** → `paid` + 记 `matched_tx_id`;**释放地址 → idle**。
- [ ] **超时**:`pending` 过期 → `expired` + 释放地址 → idle(可选冷却期后才允许重绑)。
- [ ] **孤儿**:遍历池地址的到账 tx,凡匹配不到任何活跃订单的转入 → 写 `orphan_payments`(去重 by tx_id)。
- [ ] **幂等**(tx_id 全局去重,含 orphan)、崩溃恢复(从 DB pending + 池状态重建)。TronGrid 需能取到账时间戳(`block_timestamp`)用于时间过滤。
## Phase D — 归集(复用 cmd/sweep)
- [ ] 扫池内**有余额**地址 → 冷钱包;接**能量租赁**(先手动 runbook,后续自动化);归集与订单状态解耦。
- [ ] 归集不清空 `busy` 语义;只搬余额。runbook 写清"哪些地址、多久扫一次、能量怎么租"。
## Phase E — 测试 + 真链验证
- [ ] 单测:并发建单**不抢同址**(原子性)、地址**复用后旧订单迟到付款不误配新订单**(唯一金额+时间戳)、唯一金额不撞、超时释放、孤儿记录、幂等、崩溃恢复、同用户单订单约束。
- [ ] 真链:两并发订单各绑不同地址各自付款到 paid;一单超时释放后新单**复用同址**,再对旧地址补一笔"旧金额"付款 → 应成**孤儿**、不误配新单;归集池地址到冷钱包。
## Verification / 判据
- `go test ./...`(store/pay/watcher 新逻辑全绿,重点覆盖复用错配)。
- 真链:并发 + 复用 + 迟到付款三场景表现正确;归集笔数 = 有余额的池地址数(有界)。
- 成本:激活一次/地址、归集 O(池大小);对小额高频订单不再被激活费线性拖累。
## 不在本轮 / 取舍
- GasFree(#35,免 TRX 但每笔固定 1 USDT,更适合少地址批量,另议)。
- 能量租赁自动化(先手动)。
- 池增长上限/排队(先不设,或设一个宽上限)。
- 发货侧(独角数卡 + /internal/codes/mint)另排。
@@ -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)另排。
+16 -5
View File
@@ -1,8 +1,13 @@
# pangolin-pay
自托管 USDT(TRC20) 收款服务。给每个订单派生一个**唯一收款地址**(watch-only,从账户
xpub 派生,**不持私钥**),轮询 TronGrid 侦测到账、确认后标记订单已付。归集(把钱扫到冷
钱包)是**独立的离线步骤**,本服务不碰私钥。
自托管 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
@@ -74,13 +79,19 @@ go run ./cmd/paywatch
## API
```
POST /order {"sku":"pro-year","amount":5000000} # amount = micro-USDT(1e-6)
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` 拿收款地址;支付页轮询 `GET /order/{id}` 直到 `paid`
门面(独角数卡)下单时调 `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`)
+18 -6
View File
@@ -75,10 +75,21 @@ func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
xpub := os.Getenv("PAY_ACCOUNT_XPUB")
if xpub == "" {
log.Error("PAY_ACCOUNT_XPUB is required (watch-only account xpub, m/44'/195'/0')")
os.Exit(1)
// 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")
@@ -94,9 +105,10 @@ func main() {
}
defer func() { _ = st.Close() }()
svc := pay.New(st, pay.Config{AccountXpub: xpub, OrderTTL: 15 * time.Minute})
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, log)
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()
+8 -3
View File
@@ -28,8 +28,9 @@ func New(svc *pay.Service) http.Handler {
}
type createReq struct {
SKU string `json:"sku"`
Amount int64 `json:"amount"` // micro-USDT (1e-6)
UserRef string `json:"user_ref"`
SKU string `json:"sku"`
Amount int64 `json:"amount"` // base price, micro-USDT (1e-6)
}
type orderResp struct {
@@ -64,7 +65,11 @@ func (h *Handler) createOrder(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
o, err := h.svc.CreateOrder(r.Context(), req.SKU, req.Amount)
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
+21 -16
View File
@@ -11,15 +11,15 @@ import (
"github.com/wangjia/pangolin/pay/internal/store"
)
const testXpub = "xpub6D1AabNHCupeiLM65ZR9UStMhJ1vCpyV4XbZdyhMZBiJXALQtmn9p42VTQckoHVn8WNqS7dqnJokZHAHcHGoaQgmv8D45oNUKx6DZMNZBCd"
const recvAddr = "TRecv00000000000000000000000000000A"
func TestCreateAndGetOrder(t *testing.T) {
func TestCreateGetAndConflict(t *testing.T) {
st, _ := store.Open(":memory:")
t.Cleanup(func() { _ = st.Close() })
srv := httptest.NewServer(New(pay.New(st, pay.Config{AccountXpub: testXpub})))
srv := httptest.NewServer(New(pay.New(st, pay.Config{ReceiveAddress: recvAddr})))
t.Cleanup(srv.Close)
body, _ := json.Marshal(map[string]any{"sku": "pro-year", "amount": 5_000000})
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)
@@ -30,30 +30,35 @@ func TestCreateAndGetOrder(t *testing.T) {
var created orderResp
_ = json.NewDecoder(resp.Body).Decode(&created)
_ = resp.Body.Close()
if created.Address != "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH" || created.Status != "pending" {
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)
}
var got orderResp
_ = json.NewDecoder(r2.Body).Decode(&got)
_ = r2.Body.Close()
if got.OrderNo != created.OrderNo || got.Address != created.Address {
t.Fatalf("get mismatch: %+v vs %+v", got, created)
}
r3, _ := http.Get(srv.URL + "/order/NOPE")
if r3.StatusCode != http.StatusNotFound {
t.Fatalf("want 404, got %d", r3.StatusCode)
// 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.Post(srv.URL+"/order", "application/json", bytes.NewReader([]byte(`{"sku":"x","amount":0}`)))
if r4.StatusCode != http.StatusBadRequest {
t.Fatalf("want 400 for bad amount, got %d", r4.StatusCode)
r4, _ := 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()
}
+74 -18
View File
@@ -1,22 +1,29 @@
// Package pay is the order service: create a payment (derive a fresh receiving
// address, record a pending order) and look one up.
// 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"
"github.com/wangjia/pangolin/pay/internal/wallet"
)
type Config struct {
AccountXpub string // watch-only account xpub (m/44'/195'/0')
OrderTTL time.Duration // how long a pending order stays payable
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
@@ -27,33 +34,52 @@ 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 assigns a fresh HD receiving address and records a pending order.
// amount is in micro-USDT (1e-6).
func (s *Service) CreateOrder(ctx context.Context, sku string, amount int64) (*store.Order, error) {
if amount <= 0 {
return nil, fmt.Errorf("pay: amount must be positive")
// 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")
}
idx, err := s.st.NextAddrIndex(ctx)
if err != nil {
return nil, fmt.Errorf("pay: next addr index: %w", err)
if priceMicro <= 0 {
return nil, fmt.Errorf("pay: price must be positive")
}
addr, err := wallet.AddressFromAccountXpub(s.cfg.AccountXpub, 0, idx)
if err != nil {
return nil, fmt.Errorf("pay: derive address: %w", err)
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,
AddrIndex: idx,
Address: addr,
Address: s.cfg.ReceiveAddress,
Status: store.StatusPending,
CreatedAt: now,
ExpiresAt: now.Add(s.cfg.OrderTTL),
@@ -64,6 +90,27 @@ func (s *Service) CreateOrder(ctx context.Context, sku string, amount int64) (*s
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)
}
@@ -73,3 +120,12 @@ func newOrderNo(t time.Time) string {
_, _ = 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
}
+48 -29
View File
@@ -2,57 +2,76 @@ package pay
import (
"context"
"errors"
"testing"
"github.com/wangjia/pangolin/pay/internal/store"
)
// Same golden test-mnemonic account xpub as the wallet package. First two
// receiving addresses (index 0,1) are locked so we prove CreateOrder assigns the
// right HD address and advances the cursor.
const testXpub = "xpub6D1AabNHCupeiLM65ZR9UStMhJ1vCpyV4XbZdyhMZBiJXALQtmn9p42VTQckoHVn8WNqS7dqnJokZHAHcHGoaQgmv8D45oNUKx6DZMNZBCd"
const recvAddr = "TRecv00000000000000000000000000000A"
func TestCreateOrderDerivesSequentialAddresses(t *testing.T) {
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() })
svc := New(st, Config{AccountXpub: testXpub})
return New(st, Config{ReceiveAddress: recvAddr})
}
func TestCreateOrderUniqueAmountSameAddress(t *testing.T) {
svc := newSvc(t)
ctx := context.Background()
o0, err := svc.CreateOrder(ctx, "pro-year", 5_000000)
if err != nil {
t.Fatalf("order0: %v", err)
}
if o0.AddrIndex != 0 || o0.Address != "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH" {
t.Fatalf("order0 addr: idx=%d addr=%s", o0.AddrIndex, o0.Address)
}
if o0.Status != store.StatusPending || o0.ExpiresAt.Before(o0.CreatedAt) {
t.Fatalf("order0 state: %+v", o0)
}
o1, err := svc.CreateOrder(ctx, "pro-month", 500000)
o1, err := svc.CreateOrder(ctx, "u1", "pro-year", 5_000000)
if err != nil {
t.Fatalf("order1: %v", err)
}
if o1.AddrIndex != 1 || o1.Address != "TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK" {
t.Fatalf("order1 addr: idx=%d addr=%s", o1.AddrIndex, o1.Address)
if o1.Address != recvAddr {
t.Fatalf("addr %s, want single receive address", o1.Address)
}
if o1.Address == o0.Address {
t.Fatal("addresses must not repeat across orders")
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) {
st, _ := store.Open(":memory:")
t.Cleanup(func() { _ = st.Close() })
svc := New(st, Config{AccountXpub: testXpub})
svc := newSvc(t)
ctx := context.Background()
if _, err := svc.CreateOrder(ctx, "x", 0); err == nil {
t.Fatal("expected error for non-positive amount")
if _, err := svc.CreateOrder(ctx, "", "pro", 100); err == nil {
t.Fatal("empty userRef should error")
}
if _, err := svc.CreateOrder(ctx, "", 100); err == nil {
t.Fatal("expected error for empty sku")
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")
}
}
+69 -36
View File
@@ -1,5 +1,10 @@
// Package store persists pay orders + the HD address-derivation cursor in
// SQLite (pure-Go modernc driver, no CGO — same choice as the control plane).
// Package store 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 (
@@ -19,13 +24,14 @@ const (
StatusExpired Status = "expired"
)
// Order is one payment request. Amounts are in micro-USDT (1e-6), matching the
// raw integer value of a TRC20 USDT transfer (USDT has 6 decimals).
// 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
AddrIndex uint32
Address string
Status Status
TxID string
@@ -42,7 +48,7 @@ func Open(dsn string) (*Store, error) {
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1) // SQLite: serialize writers, avoid "database is locked"
db.SetMaxOpenConns(1) // SQLite: serialize writers
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
@@ -57,9 +63,9 @@ 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,
addr_index INTEGER NOT NULL,
address TEXT NOT NULL,
status TEXT NOT NULL,
tx_id TEXT NOT NULL DEFAULT '',
@@ -67,8 +73,16 @@ func (s *Store) migrate() error {
expires_at INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_orders_status ON pay_orders(status)`,
`CREATE TABLE IF NOT EXISTS addr_cursor(id INTEGER PRIMARY KEY CHECK(id=1), next_index INTEGER NOT NULL)`,
`INSERT OR IGNORE INTO addr_cursor(id, next_index) VALUES(1, 0)`,
`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 {
@@ -78,41 +92,20 @@ func (s *Store) migrate() error {
return nil
}
// NextAddrIndex atomically returns the current HD index and advances the cursor.
// Addresses are never reused (avoids an old payment landing on a recycled slot).
func (s *Store) NextAddrIndex(ctx context.Context) (uint32, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
var idx uint32
if err := tx.QueryRowContext(ctx, `SELECT next_index FROM addr_cursor WHERE id=1`).Scan(&idx); err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, `UPDATE addr_cursor SET next_index=? WHERE id=1`, idx+1); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return idx, nil
}
func (s *Store) CreateOrder(ctx context.Context, o *Order) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO pay_orders(order_no,sku,expect_amount,addr_index,address,status,created_at,expires_at)
`INSERT INTO pay_orders(order_no,user_ref,sku,expect_amount,address,status,created_at,expires_at)
VALUES(?,?,?,?,?,?,?,?)`,
o.OrderNo, o.SKU, o.ExpectAmount, o.AddrIndex, o.Address, o.Status, o.CreatedAt.Unix(), o.ExpiresAt.Unix())
o.OrderNo, o.UserRef, o.SKU, o.ExpectAmount, o.Address, o.Status, o.CreatedAt.Unix(), o.ExpiresAt.Unix())
return err
}
const cols = `order_no,sku,expect_amount,addr_index,address,status,tx_id,created_at,expires_at`
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.SKU, &o.ExpectAmount, &o.AddrIndex, &o.Address, &o.Status, &o.TxID, &created, &expires); err != nil {
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)
@@ -146,8 +139,29 @@ func (s *Store) ListPending(ctx context.Context) ([]*Order, error) {
return out, rows.Err()
}
// MarkPaid transitions pending->paid, idempotently (only affects a still-pending
// row). Returns true if this call was the one that flipped it.
// 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=?`,
@@ -170,3 +184,22 @@ func (s *Store) MarkExpired(ctx context.Context, now time.Time) (int64, error) {
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
}
+81 -38
View File
@@ -16,17 +16,10 @@ func openMem(t *testing.T) *Store {
return s
}
func TestNextAddrIndexMonotonic(t *testing.T) {
s := openMem(t)
ctx := context.Background()
for want := uint32(0); want < 5; want++ {
got, err := s.NextAddrIndex(ctx)
if err != nil {
t.Fatalf("next: %v", err)
}
if got != want {
t.Fatalf("index got %d want %d", got, want)
}
func 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),
}
}
@@ -34,33 +27,88 @@ func TestOrderRoundtripAndMarkPaidIdempotent(t *testing.T) {
s := openMem(t)
ctx := context.Background()
now := time.Unix(1_700_000_000, 0)
o := &Order{
OrderNo: "PAY1", SKU: "pro-year", ExpectAmount: 5_000000, AddrIndex: 0,
Address: "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", Status: StatusPending,
CreatedAt: now, ExpiresAt: now.Add(15 * time.Minute),
}
if err := s.CreateOrder(ctx, o); err != nil {
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.SKU != "pro-year" || got.ExpectAmount != 5_000000 || got.Status != StatusPending {
t.Fatalf("roundtrip mismatch: %+v", got)
if got.UserRef != "u1" || got.ExpectAmount != 5_000017 || got.Status != StatusPending {
t.Fatalf("roundtrip: %+v", got)
}
ok, err := s.MarkPaid(ctx, "PAY1", "tx-abc")
ok, err := s.MarkPaid(ctx, "PAY1", "tx-a")
if err != nil || !ok {
t.Fatalf("first MarkPaid ok=%v err=%v (want true,nil)", ok, err)
t.Fatalf("first MarkPaid ok=%v err=%v", ok, err)
}
ok2, err := s.MarkPaid(ctx, "PAY1", "tx-dup")
ok2, err := s.MarkPaid(ctx, "PAY1", "tx-b")
if err != nil || ok2 {
t.Fatalf("second MarkPaid ok=%v err=%v (want false,nil — idempotent)", ok2, err)
t.Fatalf("second MarkPaid ok=%v err=%v (want false)", ok2, err)
}
got, _ = s.GetOrder(ctx, "PAY1")
if got.Status != StatusPaid || got.TxID != "tx-abc" {
t.Fatalf("after paid: status=%s tx=%s (want paid,tx-abc)", got.Status, got.TxID)
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)
}
}
@@ -68,21 +116,16 @@ func TestMarkExpired(t *testing.T) {
s := openMem(t)
ctx := context.Background()
base := time.Unix(1_700_000_000, 0)
past := &Order{OrderNo: "old", SKU: "x", ExpectAmount: 1, Address: "T1", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(1 * time.Minute)}
future := &Order{OrderNo: "new", SKU: "x", ExpectAmount: 1, Address: "T2", Status: StatusPending, CreatedAt: base, ExpiresAt: base.Add(1 * time.Hour)}
_ = s.CreateOrder(ctx, past)
_ = s.CreateOrder(ctx, future)
_ = 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 (want 1)", n, err)
t.Fatalf("MarkExpired n=%d err=%v", n, err)
}
oldO, _ := s.GetOrder(ctx, "old")
newO, _ := s.GetOrder(ctx, "new")
if oldO.Status != StatusExpired {
t.Fatalf("old should be expired, got %s", oldO.Status)
}
if newO.Status != StatusPending {
t.Fatalf("new should still be pending, got %s", newO.Status)
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)
}
}
+12 -8
View File
@@ -18,10 +18,13 @@ 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
TxID string
To string
Value int64
BlockTs int64
}
// Fetcher returns confirmed incoming USDT transfers to a given address.
@@ -67,10 +70,11 @@ func (c *Client) IncomingTransfers(ctx context.Context, address string) ([]Trans
}
var body struct {
Data []struct {
TransactionID string `json:"transaction_id"`
To string `json:"to"`
Value string `json:"value"`
Type string `json:"type"`
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 {
@@ -85,7 +89,7 @@ func (c *Client) IncomingTransfers(ctx context.Context, address string) ([]Trans
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})
out = append(out, Transfer{TxID: d.TransactionID, To: d.To, Value: v, BlockTs: d.BlockTimestamp / 1000})
}
return out, nil
}
+52 -23
View File
@@ -1,6 +1,7 @@
// Package watcher polls TronGrid for incoming USDT and marks paid orders.
// It only reads the chain and flips order state — it never holds keys or moves
// funds (sweeping is a separate offline step).
// Package watcher 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 (
@@ -13,23 +14,28 @@ import (
)
type Watcher struct {
st *store.Store
tron tron.Fetcher
log *slog.Logger
now func() time.Time
st *store.Store
tron tron.Fetcher
address string
log *slog.Logger
now func() time.Time
}
func New(st *store.Store, f tron.Fetcher, log *slog.Logger) *Watcher {
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, log: log, now: time.Now}
return &Watcher{st: st, tron: f, address: address, log: log, now: time.Now}
}
// Tick: (1) expire overdue pending orders; (2) for each still-pending order,
// look for a confirmed incoming transfer >= the expected amount on its unique
// address and mark it paid. Idempotent — a transfer seen twice flips the order
// at most once (MarkPaid only affects a still-pending row).
// 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
@@ -37,31 +43,54 @@ func (w *Watcher) Tick(ctx context.Context) error {
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 {
transfers, err := w.tron.IncomingTransfers(ctx, o.Address)
byAmount[o.ExpectAmount] = o
}
for _, t := range transfers {
handled, err := w.st.TxHandled(ctx, t.TxID)
if err != nil {
// Transient (rate limit / network): log and move on; retried next tick.
w.log.Warn("fetch transfers failed", "order", o.OrderNo, "err", err)
w.log.Error("tx handled check", "tx", t.TxID, "err", err)
continue
}
for _, t := range transfers {
if t.Value < o.ExpectAmount {
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)
break
continue
}
if ok {
w.log.Info("order paid", "order", o.OrderNo, "tx", t.TxID, "value", t.Value, "address", o.Address)
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
}
break
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
}
+136 -58
View File
@@ -9,81 +9,159 @@ import (
"github.com/wangjia/pangolin/pay/internal/tron"
)
type mockFetcher struct{ m map[string][]tron.Transfer }
const recvAddr = "TRecv00000000000000000000000000000A"
func (f *mockFetcher) IncomingTransfers(_ context.Context, addr string) ([]tron.Transfer, error) {
return f.m[addr], nil
type mockFetcher struct{ transfers []tron.Transfer }
func (f *mockFetcher) IncomingTransfers(_ context.Context, _ string) ([]tron.Transfer, error) {
return f.transfers, nil
}
func newPending(t *testing.T, st *store.Store, orderNo, addr string, amount int64, expires time.Time) {
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: orderNo, SKU: "pro", ExpectAmount: amount, Address: addr,
Status: store.StatusPending, CreatedAt: time.Unix(1_700_000_000, 0), ExpiresAt: expires,
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 order: %v", err)
t.Fatalf("seed: %v", err)
}
}
func TestWatcherMarksPaidOnSufficientTransfer(t *testing.T) {
st, _ := store.Open(":memory:")
t.Cleanup(func() { _ = st.Close() })
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)
newPending(t, st, "PAY1", "TADDR1", 5_000000, now.Add(time.Hour))
fetch := &mockFetcher{m: map[string][]tron.Transfer{}}
w := New(st, fetch, nil)
w.now = func() time.Time { return now }
// No transfer yet -> stays pending.
if err := w.Tick(ctx); err != nil {
t.Fatalf("tick1: %v", err)
}
if o, _ := st.GetOrder(ctx, "PAY1"); o.Status != store.StatusPending {
t.Fatalf("want pending, got %s", o.Status)
}
// Underpayment -> still pending.
fetch.m["TADDR1"] = []tron.Transfer{{TxID: "tx-under", To: "TADDR1", Value: 4_000000}}
_ = w.Tick(ctx)
if o, _ := st.GetOrder(ctx, "PAY1"); o.Status != store.StatusPending {
t.Fatalf("underpay should stay pending, got %s", o.Status)
}
// Sufficient payment -> paid, tx recorded.
fetch.m["TADDR1"] = []tron.Transfer{{TxID: "tx-ok", To: "TADDR1", Value: 5_000000}}
_ = w.Tick(ctx)
o, _ := st.GetOrder(ctx, "PAY1")
if o.Status != store.StatusPaid || o.TxID != "tx-ok" {
t.Fatalf("want paid/tx-ok, got %s/%s", o.Status, o.TxID)
}
// Idempotent: another tick with same transfer doesn't error or flip anything.
if err := w.Tick(ctx); err != nil {
t.Fatalf("idempotent tick: %v", err)
}
o, _ = st.GetOrder(ctx, "PAY1")
if o.Status != store.StatusPaid || o.TxID != "tx-ok" {
t.Fatalf("idempotency broken: %s/%s", o.Status, o.TxID)
}
}
func TestWatcherExpiresOverdue(t *testing.T) {
st, _ := store.Open(":memory:")
t.Cleanup(func() { _ = st.Close() })
ctx := context.Background()
now := time.Unix(1_700_000_100, 0)
newPending(t, st, "OLD", "TADDR2", 1_000000, now.Add(-time.Minute)) // already overdue
w := New(st, &mockFetcher{m: map[string][]tron.Transfer{}}, nil)
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)
}
if o, _ := st.GetOrder(ctx, "OLD"); o.Status != store.StatusExpired {
t.Fatalf("want expired, got %s", o.Status)
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)
}
}