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:
@@ -0,0 +1,41 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/wallet"
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// TransferSelector is the TRC20 transfer(address,uint256) function selector
|
||||
// string that TronGrid's triggersmartcontract expects.
|
||||
const TransferSelector = "transfer(address,uint256)"
|
||||
|
||||
// ABIEncodeTransferParams builds the 64-byte ABI parameter for
|
||||
// transfer(address,uint256): the recipient (20-byte body, left-padded to 32) and
|
||||
// the amount (uint256, left-padded to 32). Returns hex (no 0x, no 4-byte
|
||||
// selector — TronGrid derives the selector from TransferSelector).
|
||||
func ABIEncodeTransferParams(toAddr string, amount int64) (string, error) {
|
||||
if amount <= 0 {
|
||||
return "", fmt.Errorf("tron: transfer amount must be positive")
|
||||
}
|
||||
payload, err := wallet.DecodeTronAddress(toAddr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := make([]byte, 64)
|
||||
copy(out[12:32], payload[1:]) // 20-byte body, right-aligned in first word
|
||||
new(big.Int).SetInt64(amount).FillBytes(out[32:64])
|
||||
return hex.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
// keccakAddressBody derives the 20-byte address body from a 65-byte uncompressed
|
||||
// secp256k1 public key: keccak256(X||Y)[12:].
|
||||
func keccakAddressBody(uncompressed []byte) []byte {
|
||||
h := sha3.NewLegacyKeccak256()
|
||||
h.Write(uncompressed[1:])
|
||||
sum := h.Sum(nil)
|
||||
return sum[12:]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcec/v2/ecdsa"
|
||||
)
|
||||
|
||||
// TxID computes the TRON transaction id: sha256 of the raw_data bytes. The
|
||||
// signature is made over this hash. Recomputing it offline from raw_data_hex
|
||||
// (rather than trusting a txID handed over by the online builder) is what makes
|
||||
// air-gapped signing safe — a tampered raw_data yields a different id.
|
||||
func TxID(rawDataHex string) ([]byte, error) {
|
||||
raw, err := hex.DecodeString(rawDataHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tron: raw_data hex: %w", err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("tron: empty raw_data")
|
||||
}
|
||||
h := sha256.Sum256(raw)
|
||||
return h[:], nil
|
||||
}
|
||||
|
||||
// SignRawData signs raw_data with a hex private key and returns the 65-byte TRON
|
||||
// signature hex: R(32) || S(32) || recid(1, value 0/1). OFFLINE ONLY.
|
||||
func SignRawData(rawDataHex, privHex string) (string, error) {
|
||||
txid, err := TxID(rawDataHex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pb, err := hex.DecodeString(privHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tron: privkey hex: %w", err)
|
||||
}
|
||||
priv, _ := btcec.PrivKeyFromBytes(pb)
|
||||
|
||||
// SignCompact returns 65 bytes: [header || R || S], header = 27+recid for an
|
||||
// uncompressed key. TRON wants R || S || recid, so rearrange.
|
||||
compact := ecdsa.SignCompact(priv, txid, false)
|
||||
if len(compact) != 65 {
|
||||
return "", fmt.Errorf("tron: unexpected compact signature length %d", len(compact))
|
||||
}
|
||||
recid := compact[0] - 27
|
||||
sig := make([]byte, 0, 65)
|
||||
sig = append(sig, compact[1:65]...) // R || S
|
||||
sig = append(sig, recid) // recovery id 0/1
|
||||
return hex.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// RecoverAddressBody recovers the signer's 20-byte address body from a raw_data
|
||||
// hex + TRON signature hex — used by tests (and could verify a signature).
|
||||
func RecoverAddressBody(rawDataHex, sigHex string) ([]byte, error) {
|
||||
txid, err := TxID(rawDataHex)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sig, err := hex.DecodeString(sigHex)
|
||||
if err != nil || len(sig) != 65 {
|
||||
return nil, fmt.Errorf("tron: signature must be 65 bytes hex")
|
||||
}
|
||||
// Rebuild btcec compact layout: [header=27+recid || R || S].
|
||||
compact := make([]byte, 65)
|
||||
compact[0] = 27 + sig[64]
|
||||
copy(compact[1:], sig[:64])
|
||||
pub, _, err := ecdsa.RecoverCompact(compact, txid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tron: recover: %w", err)
|
||||
}
|
||||
return keccakAddressBody(pub.SerializeUncompressed()), nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/wallet"
|
||||
)
|
||||
|
||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// addr index 0 of the test mnemonic (see wallet golden vector).
|
||||
const testAddr0 = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH"
|
||||
|
||||
// TestSignRecoverRoundtrip proves the signing path is correct: signing raw_data
|
||||
// with address 0's private key yields a signature that recovers to address 0.
|
||||
// This is the crux of the money-moving path — if it holds, TRON will accept the
|
||||
// signature as coming from the owner.
|
||||
func TestSignRecoverRoundtrip(t *testing.T) {
|
||||
priv, err := wallet.PrivKeyHexFromMnemonic(testMnemonic, "", 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("privkey: %v", err)
|
||||
}
|
||||
rawHex := "0a0212340a0212341234567890abcdef" // arbitrary non-empty raw_data
|
||||
|
||||
sig, err := SignRawData(rawHex, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
if len(sig) != 130 { // 65 bytes == 130 hex chars
|
||||
t.Fatalf("signature hex len %d, want 130", len(sig))
|
||||
}
|
||||
|
||||
body, err := RecoverAddressBody(rawHex, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("recover: %v", err)
|
||||
}
|
||||
wantBody, _ := wallet.TronAddressBodyHex(testAddr0)
|
||||
if hex.EncodeToString(body) != wantBody {
|
||||
t.Fatalf("recovered body %x != address 0 body %s", body, wantBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABIEncodeTransferParams(t *testing.T) {
|
||||
p, err := ABIEncodeTransferParams(testAddr0, 5_000000)
|
||||
if err != nil {
|
||||
t.Fatalf("abi: %v", err)
|
||||
}
|
||||
if len(p) != 128 { // 64 bytes == 128 hex chars
|
||||
t.Fatalf("param hex len %d, want 128", len(p))
|
||||
}
|
||||
// bytes[12:32] must equal the 20-byte address body.
|
||||
wantBody, _ := wallet.TronAddressBodyHex(testAddr0)
|
||||
if p[24:64] != wantBody {
|
||||
t.Fatalf("recipient word %s != body %s", p[24:64], wantBody)
|
||||
}
|
||||
// bytes[0:12] must be zero padding.
|
||||
if p[0:24] != "000000000000000000000000" {
|
||||
t.Fatalf("recipient not left-padded: %s", p[0:24])
|
||||
}
|
||||
// amount word must decode to 5000000.
|
||||
amt, ok := new(big.Int).SetString(p[64:128], 16)
|
||||
if !ok || amt.Int64() != 5_000000 {
|
||||
t.Fatalf("amount word decodes to %v, want 5000000", amt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABIEncodeRejectsBad(t *testing.T) {
|
||||
if _, err := ABIEncodeTransferParams(testAddr0, 0); err == nil {
|
||||
t.Fatal("expected error on zero amount")
|
||||
}
|
||||
if _, err := ABIEncodeTransferParams("garbage", 1); err == nil {
|
||||
t.Fatal("expected error on bad address")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/wangjia/pangolin/pay/internal/wallet"
|
||||
)
|
||||
|
||||
// ⚠️ The online chain ops below (BuildTransfer / TRC20Balance / Broadcast) can
|
||||
// only be fully validated against a live TronGrid + real funds (Phase E). The
|
||||
// pure crypto (sign.go / abi.go / address.go) is unit-tested; these are not.
|
||||
|
||||
// UnsignedTx is the transaction object TronGrid returns from triggersmartcontract.
|
||||
// raw_data is kept verbatim so it round-trips unchanged into broadcast.
|
||||
type UnsignedTx struct {
|
||||
TxID string `json:"txID"`
|
||||
RawData json.RawMessage `json:"raw_data"`
|
||||
RawDataHex string `json:"raw_data_hex"`
|
||||
Visible bool `json:"visible"`
|
||||
}
|
||||
|
||||
// SignedTx is an UnsignedTx with the signature attached, ready to broadcast.
|
||||
type SignedTx struct {
|
||||
TxID string `json:"txID"`
|
||||
RawData json.RawMessage `json:"raw_data"`
|
||||
RawDataHex string `json:"raw_data_hex"`
|
||||
Visible bool `json:"visible"`
|
||||
Signature []string `json:"signature"`
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, path string, body, out any) error {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("tron: %s status %d", path, resp.StatusCode)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// BuildTransfer asks TronGrid to construct an unsigned TRC20 transfer of amount
|
||||
// (micro-USDT) from ownerAddr to toAddr. No private key involved — safe online.
|
||||
// feeLimit is in sun (1e-6 TRX); ~30 TRX (30_000_000) is a safe cap for a TRC20
|
||||
// transfer.
|
||||
func (c *Client) BuildTransfer(ctx context.Context, ownerAddr, toAddr string, amount, feeLimit int64) (*UnsignedTx, error) {
|
||||
ownerBody, err := wallet.DecodeTronAddress(ownerAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contractBody, err := wallet.DecodeTronAddress(c.usdtContract)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
param, err := ABIEncodeTransferParams(toAddr, amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody := map[string]any{
|
||||
"owner_address": hex.EncodeToString(ownerBody),
|
||||
"contract_address": hex.EncodeToString(contractBody),
|
||||
"function_selector": TransferSelector,
|
||||
"parameter": param,
|
||||
"fee_limit": feeLimit,
|
||||
"call_value": 0,
|
||||
"visible": false,
|
||||
}
|
||||
var resp struct {
|
||||
Transaction UnsignedTx `json:"transaction"`
|
||||
Result struct {
|
||||
Result bool `json:"result"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := c.postJSON(ctx, "/wallet/triggersmartcontract", reqBody, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Transaction.RawDataHex == "" {
|
||||
return nil, fmt.Errorf("tron: build transfer failed: %s %s", resp.Result.Code, decodeHexMessage(resp.Result.Message))
|
||||
}
|
||||
return &resp.Transaction, nil
|
||||
}
|
||||
|
||||
// TRC20Balance returns the address's USDT balance in micro-USDT.
|
||||
func (c *Client) TRC20Balance(ctx context.Context, addr string) (int64, error) {
|
||||
u := fmt.Sprintf("/v1/accounts/%s", addr)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+u, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("TRON-PRO-API-KEY", c.apiKey)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("tron: account status %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data []struct {
|
||||
TRC20 []map[string]string `json:"trc20"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(body.Data) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
for _, m := range body.Data[0].TRC20 {
|
||||
if v, ok := m[c.usdtContract]; ok {
|
||||
return strconv.ParseInt(v, 10, 64)
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Broadcast submits a signed transaction. Returns the on-chain txid on success.
|
||||
func (c *Client) Broadcast(ctx context.Context, tx *SignedTx) (string, error) {
|
||||
var resp struct {
|
||||
Result bool `json:"result"`
|
||||
Txid string `json:"txid"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.postJSON(ctx, "/wallet/broadcasttransaction", tx, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !resp.Result {
|
||||
return "", fmt.Errorf("tron: broadcast rejected: %s %s", resp.Code, decodeHexMessage(resp.Message))
|
||||
}
|
||||
return resp.Txid, nil
|
||||
}
|
||||
|
||||
// decodeHexMessage best-effort decodes TronGrid's hex-encoded error messages.
|
||||
func decodeHexMessage(s string) string {
|
||||
if b, err := hex.DecodeString(s); err == nil && len(b) > 0 {
|
||||
return string(b)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/base58"
|
||||
)
|
||||
|
||||
// DecodeTronAddress decodes a base58check TRON address ("T...") to its 21-byte
|
||||
// payload (0x41 || 20-byte body), validating the checksum. The 20-byte body
|
||||
// (payload[1:]) is what TRON ABI parameters use (left-padded to 32 bytes).
|
||||
func DecodeTronAddress(addr string) ([]byte, error) {
|
||||
raw := base58.Decode(addr)
|
||||
if len(raw) != 25 { // 21 payload + 4 checksum
|
||||
return nil, fmt.Errorf("wallet: bad TRON address length %d", len(raw))
|
||||
}
|
||||
payload, sum := raw[:21], raw[21:]
|
||||
h1 := sha256.Sum256(payload)
|
||||
h2 := sha256.Sum256(h1[:])
|
||||
if !bytes.Equal(h2[:4], sum) {
|
||||
return nil, fmt.Errorf("wallet: bad TRON address checksum")
|
||||
}
|
||||
if payload[0] != tronAddrPrefix {
|
||||
return nil, fmt.Errorf("wallet: bad TRON address prefix 0x%02x", payload[0])
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// TronAddressBodyHex returns the 20-byte address body as hex (no 0x41 prefix) —
|
||||
// used to build/verify ABI-encoded transfer recipients.
|
||||
func TronAddressBodyHex(addr string) (string, error) {
|
||||
payload, err := DecodeTronAddress(addr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", payload[1:]), nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package wallet
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecodeTronAddressRoundtrip(t *testing.T) {
|
||||
// Golden addresses from the test mnemonic (see derive_test.go).
|
||||
for _, addr := range goldenAddrs {
|
||||
payload, err := DecodeTronAddress(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("decode %s: %v", addr, err)
|
||||
}
|
||||
if len(payload) != 21 || payload[0] != 0x41 {
|
||||
t.Fatalf("bad payload for %s: %x", addr, payload)
|
||||
}
|
||||
// Re-encode the payload and expect the same address back.
|
||||
if got := base58CheckEncode(payload); got != addr {
|
||||
t.Fatalf("roundtrip: %s -> %s", addr, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTronAddressRejectsBad(t *testing.T) {
|
||||
if _, err := DecodeTronAddress("TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdX"); err == nil {
|
||||
t.Fatal("expected checksum failure on tampered address")
|
||||
}
|
||||
if _, err := DecodeTronAddress("not-an-address"); err == nil {
|
||||
t.Fatal("expected failure on garbage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTronAddressBodyHex(t *testing.T) {
|
||||
body, err := TronAddressBodyHex(goldenAddrs[0])
|
||||
if err != nil {
|
||||
t.Fatalf("body hex: %v", err)
|
||||
}
|
||||
if len(body) != 40 { // 20 bytes -> 40 hex chars
|
||||
t.Fatalf("body hex len %d, want 40 (%s)", len(body), body)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user