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>
This commit is contained in:
wangjia
2026-07-09 04:12:04 +08:00
parent 4bb92209ca
commit 7cf87a764f
11 changed files with 742 additions and 4 deletions
+29 -3
View File
@@ -48,10 +48,36 @@ GET /healthz → 200 ok
门面(独角数卡)下单时调 `POST /order` 拿收款地址;支付页轮询 `GET /order/{id}` 直到 `paid`
## Phase D —— 归集(离线,后续)
## Phase D —— 归集(气隙签名,`cmd/sweep`)
`internal/wallet/seed.go` 提供离线派生私钥的原语(`PrivKeyHexFromMnemonic`)。完整的 sweep
(构造 TRC20 transfer → 离线签名 → 广播)+ gas 处理 + runbook 在后续切片实现。
把散在各收款地址的 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/签名可恢复)已单测。首次务必**小额**实跑一遍再放量。
## 测试
+255
View File
@@ -0,0 +1,255 @@
// Command sweep is the offline-signed USDT 归集 tool (Phase D). It splits the
// work across the air gap so the mnemonic never touches an online machine:
//
// sweep plan (online) list derived receiving addresses that hold USDT
// sweep build (online) build unsigned transfers -> unsigned.json (no key)
// sweep sign (OFFLINE) verify + sign with the mnemonic -> signed.json
// sweep broadcast (online) submit signed.json to the chain
//
// The mnemonic is read from env PAY_SWEEP_MNEMONIC (set on the air-gapped box
// from Bitwarden), never a CLI arg and never on the hot service. `sign`
// independently re-verifies every transaction (recomputes the txid from
// raw_data, checks the recipient+amount+contract are embedded, and checks the
// derived key matches the owner) so a compromised online builder cannot trick it
// into signing a payment to someone else.
package main
import (
"context"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/wangjia/pangolin/pay/internal/tron"
"github.com/wangjia/pangolin/pay/internal/wallet"
)
type item struct {
Index uint32 `json:"index"`
Owner string `json:"owner"`
Cold string `json:"cold"`
Amount int64 `json:"amount"` // micro-USDT
Unsigned tron.UnsignedTx `json:"unsigned"`
Signed *tron.SignedTx `json:"signed,omitempty"`
}
func main() {
if len(os.Args) < 2 {
fail("usage: sweep <plan|build|sign|broadcast> [flags]")
}
switch os.Args[1] {
case "plan":
cmdPlan(os.Args[2:])
case "build":
cmdBuild(os.Args[2:])
case "sign":
cmdSign(os.Args[2:])
case "broadcast":
cmdBroadcast(os.Args[2:])
default:
fail("unknown subcommand %q (plan|build|sign|broadcast)", os.Args[1])
}
}
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func usdtContract() string { return env("USDT_CONTRACT", tron.USDTContractMainnet) }
func newClient() *tron.Client {
return tron.NewClient(env("TRONGRID_BASE", ""), usdtContract(), os.Getenv("TRONGRID_API_KEY"))
}
// cmdPlan (online): scan indices 0..max, print those with a USDT balance.
func cmdPlan(args []string) {
fs := flag.NewFlagSet("plan", flag.ExitOnError)
max := fs.Uint("max", 50, "highest address index to scan")
_ = fs.Parse(args)
xpub := mustEnv("PAY_ACCOUNT_XPUB")
c := newClient()
ctx := context.Background()
var total int64
for i := uint32(0); i <= uint32(*max); i++ {
addr, err := wallet.AddressFromAccountXpub(xpub, 0, i)
if err != nil {
fail("derive %d: %v", i, err)
}
bal, err := c.TRC20Balance(ctx, addr)
if err != nil {
fmt.Fprintf(os.Stderr, "warn: balance %d %s: %v\n", i, addr, err)
continue
}
if bal > 0 {
total += bal
fmt.Printf("index=%d\taddr=%s\tusdt=%s\n", i, addr, usdt(bal))
}
}
fmt.Printf("TOTAL: %s USDT\n", usdt(total))
}
// cmdBuild (online): build unsigned transfers of each address's full balance to
// the cold wallet. No private key used.
func cmdBuild(args []string) {
fs := flag.NewFlagSet("build", flag.ExitOnError)
cold := fs.String("cold", "", "cold wallet destination address (T...)")
max := fs.Uint("max", 50, "highest address index to scan")
feeLimit := fs.Int64("fee-limit", 30_000_000, "fee limit in sun (1e-6 TRX)")
_ = fs.Parse(args)
if *cold == "" {
fail("--cold is required")
}
if _, err := wallet.DecodeTronAddress(*cold); err != nil {
fail("bad --cold: %v", err)
}
xpub := mustEnv("PAY_ACCOUNT_XPUB")
c := newClient()
ctx := context.Background()
var out []item
for i := uint32(0); i <= uint32(*max); i++ {
owner, err := wallet.AddressFromAccountXpub(xpub, 0, i)
if err != nil {
fail("derive %d: %v", i, err)
}
bal, err := c.TRC20Balance(ctx, owner)
if err != nil || bal <= 0 {
continue
}
ut, err := c.BuildTransfer(ctx, owner, *cold, bal, *feeLimit)
if err != nil {
fail("build %d %s: %v", i, owner, err)
}
out = append(out, item{Index: i, Owner: owner, Cold: *cold, Amount: bal, Unsigned: *ut})
fmt.Fprintf(os.Stderr, "built index=%d %s -> %s %s USDT\n", i, owner, *cold, usdt(bal))
}
emit(out)
}
// cmdSign (OFFLINE): verify each tx independently, then sign with the mnemonic.
func cmdSign(args []string) {
fs := flag.NewFlagSet("sign", flag.ExitOnError)
cold := fs.String("cold", "", "expected cold destination (guards against tampering)")
_ = fs.Parse(args)
mnemonic := mustEnv("PAY_SWEEP_MNEMONIC") // set on the air-gapped box from Bitwarden
items := read()
contractBodyHex, err := wallet.TronAddressBodyHex(usdtContract())
if err != nil {
fail("usdt contract: %v", err)
}
for i := range items {
it := &items[i]
if *cold != "" && it.Cold != *cold {
fail("index %d: cold %s != expected %s", it.Index, it.Cold, *cold)
}
// 1) raw_data integrity: recompute txid, must equal the claimed one.
txid, err := tron.TxID(it.Unsigned.RawDataHex)
if err != nil {
fail("index %d: txid: %v", it.Index, err)
}
if !strings.EqualFold(hex.EncodeToString(txid), it.Unsigned.TxID) {
fail("index %d: txid mismatch — raw_data tampered", it.Index)
}
// 2) recipient + amount: the exact ABI param must be embedded in raw_data.
wantParam, err := tron.ABIEncodeTransferParams(it.Cold, it.Amount)
if err != nil {
fail("index %d: abi: %v", it.Index, err)
}
if !strings.Contains(strings.ToLower(it.Unsigned.RawDataHex), strings.ToLower(wantParam)) {
fail("index %d: recipient/amount not found in raw_data — refusing to sign", it.Index)
}
// 3) contract: the USDT contract body must be in raw_data (right token).
if !strings.Contains(strings.ToLower(it.Unsigned.RawDataHex), strings.ToLower(contractBodyHex)) {
fail("index %d: USDT contract not found in raw_data — refusing to sign", it.Index)
}
// 4) key: the derived address for this index must equal the owner.
addr, err := wallet.AddressFromMnemonic(mnemonic, "", 0, 0, it.Index)
if err != nil {
fail("index %d: derive addr: %v", it.Index, err)
}
if addr != it.Owner {
fail("index %d: derived %s != owner %s — wrong mnemonic/index", it.Index, addr, it.Owner)
}
priv, err := wallet.PrivKeyHexFromMnemonic(mnemonic, "", 0, 0, it.Index)
if err != nil {
fail("index %d: privkey: %v", it.Index, err)
}
sig, err := tron.SignRawData(it.Unsigned.RawDataHex, priv)
if err != nil {
fail("index %d: sign: %v", it.Index, err)
}
it.Signed = &tron.SignedTx{
TxID: it.Unsigned.TxID,
RawData: it.Unsigned.RawData,
RawDataHex: it.Unsigned.RawDataHex,
Visible: it.Unsigned.Visible,
Signature: []string{sig},
}
fmt.Fprintf(os.Stderr, "signed index=%d %s -> %s %s USDT\n", it.Index, it.Owner, it.Cold, usdt(it.Amount))
}
emit(items)
}
// cmdBroadcast (online): submit each signed tx.
func cmdBroadcast(args []string) {
_ = flag.NewFlagSet("broadcast", flag.ExitOnError).Parse(args)
items := read()
c := newClient()
ctx := context.Background()
for i := range items {
it := &items[i]
if it.Signed == nil {
fail("index %d: not signed", it.Index)
}
txid, err := c.Broadcast(ctx, it.Signed)
if err != nil {
fail("index %d: broadcast: %v", it.Index, err)
}
fmt.Printf("broadcast index=%d %s USDT -> tx %s\n", it.Index, usdt(it.Amount), txid)
time.Sleep(200 * time.Millisecond) // be gentle with the endpoint
}
}
// --- helpers ---
func usdt(micro int64) string {
return strconv.FormatFloat(float64(micro)/1e6, 'f', 6, 64)
}
func mustEnv(k string) string {
v := os.Getenv(k)
if v == "" {
fail("env %s is required", k)
}
return v
}
func read() []item {
var items []item
if err := json.NewDecoder(os.Stdin).Decode(&items); err != nil {
fail("read json from stdin: %v", err)
}
return items
}
func emit(items []item) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(items); err != nil {
fail("write json: %v", err)
}
}
func fail(format string, a ...any) {
fmt.Fprintf(os.Stderr, "sweep: "+format+"\n", a...)
os.Exit(1)
}
+1 -1
View File
@@ -4,6 +4,7 @@ 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
@@ -11,7 +12,6 @@ require (
)
require (
github.com/btcsuite/btcd/btcec/v2 v2.3.5 // indirect
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
+1
View File
@@ -8,6 +8,7 @@ github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6
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=
+41
View File
@@ -0,0 +1,41 @@
package tron
import (
"encoding/hex"
"fmt"
"math/big"
"github.com/wangjia/pangolin/pay/internal/wallet"
"golang.org/x/crypto/sha3"
)
// TransferSelector is the TRC20 transfer(address,uint256) function selector
// string that TronGrid's triggersmartcontract expects.
const TransferSelector = "transfer(address,uint256)"
// ABIEncodeTransferParams builds the 64-byte ABI parameter for
// transfer(address,uint256): the recipient (20-byte body, left-padded to 32) and
// the amount (uint256, left-padded to 32). Returns hex (no 0x, no 4-byte
// selector — TronGrid derives the selector from TransferSelector).
func ABIEncodeTransferParams(toAddr string, amount int64) (string, error) {
if amount <= 0 {
return "", fmt.Errorf("tron: transfer amount must be positive")
}
payload, err := wallet.DecodeTronAddress(toAddr)
if err != nil {
return "", err
}
out := make([]byte, 64)
copy(out[12:32], payload[1:]) // 20-byte body, right-aligned in first word
new(big.Int).SetInt64(amount).FillBytes(out[32:64])
return hex.EncodeToString(out), nil
}
// keccakAddressBody derives the 20-byte address body from a 65-byte uncompressed
// secp256k1 public key: keccak256(X||Y)[12:].
func keccakAddressBody(uncompressed []byte) []byte {
h := sha3.NewLegacyKeccak256()
h.Write(uncompressed[1:])
sum := h.Sum(nil)
return sum[12:]
}
+74
View File
@@ -0,0 +1,74 @@
package tron
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/ecdsa"
)
// TxID computes the TRON transaction id: sha256 of the raw_data bytes. The
// signature is made over this hash. Recomputing it offline from raw_data_hex
// (rather than trusting a txID handed over by the online builder) is what makes
// air-gapped signing safe — a tampered raw_data yields a different id.
func TxID(rawDataHex string) ([]byte, error) {
raw, err := hex.DecodeString(rawDataHex)
if err != nil {
return nil, fmt.Errorf("tron: raw_data hex: %w", err)
}
if len(raw) == 0 {
return nil, fmt.Errorf("tron: empty raw_data")
}
h := sha256.Sum256(raw)
return h[:], nil
}
// SignRawData signs raw_data with a hex private key and returns the 65-byte TRON
// signature hex: R(32) || S(32) || recid(1, value 0/1). OFFLINE ONLY.
func SignRawData(rawDataHex, privHex string) (string, error) {
txid, err := TxID(rawDataHex)
if err != nil {
return "", err
}
pb, err := hex.DecodeString(privHex)
if err != nil {
return "", fmt.Errorf("tron: privkey hex: %w", err)
}
priv, _ := btcec.PrivKeyFromBytes(pb)
// SignCompact returns 65 bytes: [header || R || S], header = 27+recid for an
// uncompressed key. TRON wants R || S || recid, so rearrange.
compact := ecdsa.SignCompact(priv, txid, false)
if len(compact) != 65 {
return "", fmt.Errorf("tron: unexpected compact signature length %d", len(compact))
}
recid := compact[0] - 27
sig := make([]byte, 0, 65)
sig = append(sig, compact[1:65]...) // R || S
sig = append(sig, recid) // recovery id 0/1
return hex.EncodeToString(sig), nil
}
// RecoverAddressBody recovers the signer's 20-byte address body from a raw_data
// hex + TRON signature hex — used by tests (and could verify a signature).
func RecoverAddressBody(rawDataHex, sigHex string) ([]byte, error) {
txid, err := TxID(rawDataHex)
if err != nil {
return nil, err
}
sig, err := hex.DecodeString(sigHex)
if err != nil || len(sig) != 65 {
return nil, fmt.Errorf("tron: signature must be 65 bytes hex")
}
// Rebuild btcec compact layout: [header=27+recid || R || S].
compact := make([]byte, 65)
compact[0] = 27 + sig[64]
copy(compact[1:], sig[:64])
pub, _, err := ecdsa.RecoverCompact(compact, txid)
if err != nil {
return nil, fmt.Errorf("tron: recover: %w", err)
}
return keccakAddressBody(pub.SerializeUncompressed()), nil
}
+76
View File
@@ -0,0 +1,76 @@
package tron
import (
"encoding/hex"
"math/big"
"testing"
"github.com/wangjia/pangolin/pay/internal/wallet"
)
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// addr index 0 of the test mnemonic (see wallet golden vector).
const testAddr0 = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH"
// TestSignRecoverRoundtrip proves the signing path is correct: signing raw_data
// with address 0's private key yields a signature that recovers to address 0.
// This is the crux of the money-moving path — if it holds, TRON will accept the
// signature as coming from the owner.
func TestSignRecoverRoundtrip(t *testing.T) {
priv, err := wallet.PrivKeyHexFromMnemonic(testMnemonic, "", 0, 0, 0)
if err != nil {
t.Fatalf("privkey: %v", err)
}
rawHex := "0a0212340a0212341234567890abcdef" // arbitrary non-empty raw_data
sig, err := SignRawData(rawHex, priv)
if err != nil {
t.Fatalf("sign: %v", err)
}
if len(sig) != 130 { // 65 bytes == 130 hex chars
t.Fatalf("signature hex len %d, want 130", len(sig))
}
body, err := RecoverAddressBody(rawHex, sig)
if err != nil {
t.Fatalf("recover: %v", err)
}
wantBody, _ := wallet.TronAddressBodyHex(testAddr0)
if hex.EncodeToString(body) != wantBody {
t.Fatalf("recovered body %x != address 0 body %s", body, wantBody)
}
}
func TestABIEncodeTransferParams(t *testing.T) {
p, err := ABIEncodeTransferParams(testAddr0, 5_000000)
if err != nil {
t.Fatalf("abi: %v", err)
}
if len(p) != 128 { // 64 bytes == 128 hex chars
t.Fatalf("param hex len %d, want 128", len(p))
}
// bytes[12:32] must equal the 20-byte address body.
wantBody, _ := wallet.TronAddressBodyHex(testAddr0)
if p[24:64] != wantBody {
t.Fatalf("recipient word %s != body %s", p[24:64], wantBody)
}
// bytes[0:12] must be zero padding.
if p[0:24] != "000000000000000000000000" {
t.Fatalf("recipient not left-padded: %s", p[0:24])
}
// amount word must decode to 5000000.
amt, ok := new(big.Int).SetString(p[64:128], 16)
if !ok || amt.Int64() != 5_000000 {
t.Fatalf("amount word decodes to %v, want 5000000", amt)
}
}
func TestABIEncodeRejectsBad(t *testing.T) {
if _, err := ABIEncodeTransferParams(testAddr0, 0); err == nil {
t.Fatal("expected error on zero amount")
}
if _, err := ABIEncodeTransferParams("garbage", 1); err == nil {
t.Fatal("expected error on bad address")
}
}
+164
View File
@@ -0,0 +1,164 @@
package tron
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/wangjia/pangolin/pay/internal/wallet"
)
// ⚠️ The online chain ops below (BuildTransfer / TRC20Balance / Broadcast) can
// only be fully validated against a live TronGrid + real funds (Phase E). The
// pure crypto (sign.go / abi.go / address.go) is unit-tested; these are not.
// UnsignedTx is the transaction object TronGrid returns from triggersmartcontract.
// raw_data is kept verbatim so it round-trips unchanged into broadcast.
type UnsignedTx struct {
TxID string `json:"txID"`
RawData json.RawMessage `json:"raw_data"`
RawDataHex string `json:"raw_data_hex"`
Visible bool `json:"visible"`
}
// SignedTx is an UnsignedTx with the signature attached, ready to broadcast.
type SignedTx struct {
TxID string `json:"txID"`
RawData json.RawMessage `json:"raw_data"`
RawDataHex string `json:"raw_data_hex"`
Visible bool `json:"visible"`
Signature []string `json:"signature"`
}
func (c *Client) postJSON(ctx context.Context, path string, body, out any) error {
buf, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, bytes.NewReader(buf))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
}
resp, err := c.hc.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("tron: %s status %d", path, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// BuildTransfer asks TronGrid to construct an unsigned TRC20 transfer of amount
// (micro-USDT) from ownerAddr to toAddr. No private key involved — safe online.
// feeLimit is in sun (1e-6 TRX); ~30 TRX (30_000_000) is a safe cap for a TRC20
// transfer.
func (c *Client) BuildTransfer(ctx context.Context, ownerAddr, toAddr string, amount, feeLimit int64) (*UnsignedTx, error) {
ownerBody, err := wallet.DecodeTronAddress(ownerAddr)
if err != nil {
return nil, err
}
contractBody, err := wallet.DecodeTronAddress(c.usdtContract)
if err != nil {
return nil, err
}
param, err := ABIEncodeTransferParams(toAddr, amount)
if err != nil {
return nil, err
}
reqBody := map[string]any{
"owner_address": hex.EncodeToString(ownerBody),
"contract_address": hex.EncodeToString(contractBody),
"function_selector": TransferSelector,
"parameter": param,
"fee_limit": feeLimit,
"call_value": 0,
"visible": false,
}
var resp struct {
Transaction UnsignedTx `json:"transaction"`
Result struct {
Result bool `json:"result"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"result"`
}
if err := c.postJSON(ctx, "/wallet/triggersmartcontract", reqBody, &resp); err != nil {
return nil, err
}
if resp.Transaction.RawDataHex == "" {
return nil, fmt.Errorf("tron: build transfer failed: %s %s", resp.Result.Code, decodeHexMessage(resp.Result.Message))
}
return &resp.Transaction, nil
}
// TRC20Balance returns the address's USDT balance in micro-USDT.
func (c *Client) TRC20Balance(ctx context.Context, addr string) (int64, error) {
u := fmt.Sprintf("/v1/accounts/%s", addr)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+u, nil)
if err != nil {
return 0, err
}
if c.apiKey != "" {
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
}
resp, err := c.hc.Do(req)
if err != nil {
return 0, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("tron: account status %d", resp.StatusCode)
}
var body struct {
Data []struct {
TRC20 []map[string]string `json:"trc20"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return 0, err
}
if len(body.Data) == 0 {
return 0, nil
}
for _, m := range body.Data[0].TRC20 {
if v, ok := m[c.usdtContract]; ok {
return strconv.ParseInt(v, 10, 64)
}
}
return 0, nil
}
// Broadcast submits a signed transaction. Returns the on-chain txid on success.
func (c *Client) Broadcast(ctx context.Context, tx *SignedTx) (string, error) {
var resp struct {
Result bool `json:"result"`
Txid string `json:"txid"`
Code string `json:"code"`
Message string `json:"message"`
}
if err := c.postJSON(ctx, "/wallet/broadcasttransaction", tx, &resp); err != nil {
return "", err
}
if !resp.Result {
return "", fmt.Errorf("tron: broadcast rejected: %s %s", resp.Code, decodeHexMessage(resp.Message))
}
return resp.Txid, nil
}
// decodeHexMessage best-effort decodes TronGrid's hex-encoded error messages.
func decodeHexMessage(s string) string {
if b, err := hex.DecodeString(s); err == nil && len(b) > 0 {
return string(b)
}
return s
}
+39
View File
@@ -0,0 +1,39 @@
package wallet
import (
"bytes"
"crypto/sha256"
"fmt"
"github.com/btcsuite/btcd/btcutil/base58"
)
// DecodeTronAddress decodes a base58check TRON address ("T...") to its 21-byte
// payload (0x41 || 20-byte body), validating the checksum. The 20-byte body
// (payload[1:]) is what TRON ABI parameters use (left-padded to 32 bytes).
func DecodeTronAddress(addr string) ([]byte, error) {
raw := base58.Decode(addr)
if len(raw) != 25 { // 21 payload + 4 checksum
return nil, fmt.Errorf("wallet: bad TRON address length %d", len(raw))
}
payload, sum := raw[:21], raw[21:]
h1 := sha256.Sum256(payload)
h2 := sha256.Sum256(h1[:])
if !bytes.Equal(h2[:4], sum) {
return nil, fmt.Errorf("wallet: bad TRON address checksum")
}
if payload[0] != tronAddrPrefix {
return nil, fmt.Errorf("wallet: bad TRON address prefix 0x%02x", payload[0])
}
return payload, nil
}
// TronAddressBodyHex returns the 20-byte address body as hex (no 0x41 prefix) —
// used to build/verify ABI-encoded transfer recipients.
func TronAddressBodyHex(addr string) (string, error) {
payload, err := DecodeTronAddress(addr)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", payload[1:]), nil
}
+39
View File
@@ -0,0 +1,39 @@
package wallet
import "testing"
func TestDecodeTronAddressRoundtrip(t *testing.T) {
// Golden addresses from the test mnemonic (see derive_test.go).
for _, addr := range goldenAddrs {
payload, err := DecodeTronAddress(addr)
if err != nil {
t.Fatalf("decode %s: %v", addr, err)
}
if len(payload) != 21 || payload[0] != 0x41 {
t.Fatalf("bad payload for %s: %x", addr, payload)
}
// Re-encode the payload and expect the same address back.
if got := base58CheckEncode(payload); got != addr {
t.Fatalf("roundtrip: %s -> %s", addr, got)
}
}
}
func TestDecodeTronAddressRejectsBad(t *testing.T) {
if _, err := DecodeTronAddress("TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdX"); err == nil {
t.Fatal("expected checksum failure on tampered address")
}
if _, err := DecodeTronAddress("not-an-address"); err == nil {
t.Fatal("expected failure on garbage")
}
}
func TestTronAddressBodyHex(t *testing.T) {
body, err := TronAddressBodyHex(goldenAddrs[0])
if err != nil {
t.Fatalf("body hex: %v", err)
}
if len(body) != 40 { // 20 bytes -> 40 hex chars
t.Fatalf("body hex len %d, want 40 (%s)", len(body), body)
}
}
+23
View File
@@ -84,3 +84,26 @@ func PrivKeyHexFromMnemonic(mnemonic, passphrase string, account, change, index
}
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
}