From 7cf87a764ff3cc377fc3c0c891aad02037393b41 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 9 Jul 2026 04:12:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(pay):=20Phase=20D=20=E5=BD=92=E9=9B=86=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=B0=94=E9=9A=99=E7=AD=BE=E5=90=8D=20swe?= =?UTF-8?q?ep(#34/34A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 动钱代码,按"联网建/广播 + 离线签"气隙流程,离线签名独立验签防篡改: - 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 --- pay/README.md | 32 +++- pay/cmd/sweep/main.go | 255 ++++++++++++++++++++++++++++ pay/go.mod | 2 +- pay/go.sum | 1 + pay/internal/tron/abi.go | 41 +++++ pay/internal/tron/sign.go | 74 ++++++++ pay/internal/tron/sign_test.go | 76 +++++++++ pay/internal/tron/tx.go | 164 ++++++++++++++++++ pay/internal/wallet/address.go | 39 +++++ pay/internal/wallet/address_test.go | 39 +++++ pay/internal/wallet/seed.go | 23 +++ 11 files changed, 742 insertions(+), 4 deletions(-) create mode 100644 pay/cmd/sweep/main.go create mode 100644 pay/internal/tron/abi.go create mode 100644 pay/internal/tron/sign.go create mode 100644 pay/internal/tron/sign_test.go create mode 100644 pay/internal/tron/tx.go create mode 100644 pay/internal/wallet/address.go create mode 100644 pay/internal/wallet/address_test.go diff --git a/pay/README.md b/pay/README.md index 4335caf..82cf5c6 100644 --- a/pay/README.md +++ b/pay/README.md @@ -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/签名可恢复)已单测。首次务必**小额**实跑一遍再放量。 ## 测试 diff --git a/pay/cmd/sweep/main.go b/pay/cmd/sweep/main.go new file mode 100644 index 0000000..06ba5e4 --- /dev/null +++ b/pay/cmd/sweep/main.go @@ -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 [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) +} diff --git a/pay/go.mod b/pay/go.mod index 3df7d01..2ccfa25 100644 --- a/pay/go.mod +++ b/pay/go.mod @@ -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 diff --git a/pay/go.sum b/pay/go.sum index 57e6ef8..73d2d40 100644 --- a/pay/go.sum +++ b/pay/go.sum @@ -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= diff --git a/pay/internal/tron/abi.go b/pay/internal/tron/abi.go new file mode 100644 index 0000000..cead4e1 --- /dev/null +++ b/pay/internal/tron/abi.go @@ -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:] +} diff --git a/pay/internal/tron/sign.go b/pay/internal/tron/sign.go new file mode 100644 index 0000000..1fe854b --- /dev/null +++ b/pay/internal/tron/sign.go @@ -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 +} diff --git a/pay/internal/tron/sign_test.go b/pay/internal/tron/sign_test.go new file mode 100644 index 0000000..fa6e41d --- /dev/null +++ b/pay/internal/tron/sign_test.go @@ -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") + } +} diff --git a/pay/internal/tron/tx.go b/pay/internal/tron/tx.go new file mode 100644 index 0000000..d762052 --- /dev/null +++ b/pay/internal/tron/tx.go @@ -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 +} diff --git a/pay/internal/wallet/address.go b/pay/internal/wallet/address.go new file mode 100644 index 0000000..34d21cf --- /dev/null +++ b/pay/internal/wallet/address.go @@ -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 +} diff --git a/pay/internal/wallet/address_test.go b/pay/internal/wallet/address_test.go new file mode 100644 index 0000000..649590e --- /dev/null +++ b/pay/internal/wallet/address_test.go @@ -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) + } +} diff --git a/pay/internal/wallet/seed.go b/pay/internal/wallet/seed.go index 880472b..87c41d6 100644 --- a/pay/internal/wallet/seed.go +++ b/pay/internal/wallet/seed.go @@ -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'/'// +// 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 +}