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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 04:12:04 +08:00

256 lines
7.8 KiB
Go

// 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)
}