Files
pangolin/pay/internal/wallet/derive.go
wangjia 4393edf1d7 feat(pay): pangolin-pay 钱包派生模块(#34/34A · 加密货币交易引擎 Phase B.2)
新建独立 Go module pay/(pangolin-pay:独立 VPS 跑,与控制面分开、隔离 crypto 依赖)。
wallet 包:
- AddressFromAccountXpub(xpub, change, index):watch-only 从账户 xpub 派生 TRON 地址
  (Keccak-256 legacy → 后20字节 → 0x41 → base58check),watcher 用,不碰私钥。
- seed.go(离线专用):助记词→account xpub / 地址私钥(hex),给 Phase A 导 xpub、Phase D 归集签名。
- 测试:①派生一致性——xpub 路径与私钥路径逐个相等(证明每个收款地址对得上签名私钥);
  ②金标准向量锁定实现防回归。⚠️ 金标准需按 A.4 用 Ian Coleman 交叉核对一次。

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

79 lines
3.0 KiB
Go

// Package wallet derives TRON (TRC20) receiving addresses from a BIP32 account
// extended public key (xpub) — watch-only, no private keys involved. The
// pangolin-pay watcher uses AddressFromAccountXpub to assign a unique receiving
// address per order (m/44'/195'/0'/0/i). Private-key material (seed.go) is for
// OFFLINE use only (sweep signing / vector generation), never on the hot service.
package wallet
import (
"crypto/sha256"
"fmt"
"github.com/btcsuite/btcd/btcutil/base58"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
"golang.org/x/crypto/sha3"
)
// tronAddrPrefix is the TRON mainnet address version byte (0x41). It is prepended
// to the 20-byte address body before Base58Check encoding, yielding the familiar
// "T..." addresses.
const tronAddrPrefix = 0x41
// AddressFromAccountXpub derives the TRON address at m/…/<change>/<index> from an
// account-level extended public key (e.g. the xpub of m/44'/195'/0'). It is
// watch-only: an xpub can derive child addresses/public keys but never private
// keys, so this is safe to run on an internet-facing service.
//
// change is 0 for the external (receiving) chain; index is the per-order address
// index. Both are non-hardened, which is exactly why the account-level xpub can
// derive them.
func AddressFromAccountXpub(xpub string, change, index uint32) (string, error) {
acct, err := hdkeychain.NewKeyFromString(xpub)
if err != nil {
return "", fmt.Errorf("wallet: parse xpub: %w", err)
}
if acct.IsPrivate() {
return "", fmt.Errorf("wallet: expected an xpub (public extended key), got a private one")
}
chainKey, err := acct.Derive(change)
if err != nil {
return "", fmt.Errorf("wallet: derive change %d: %w", change, err)
}
addrKey, err := chainKey.Derive(index)
if err != nil {
return "", fmt.Errorf("wallet: derive index %d: %w", index, err)
}
pub, err := addrKey.ECPubKey()
if err != nil {
return "", fmt.Errorf("wallet: ec pubkey: %w", err)
}
return PubKeyToTronAddress(pub.SerializeUncompressed()), nil
}
// PubKeyToTronAddress converts a 65-byte uncompressed secp256k1 public key
// (0x04 || X || Y) to a TRON Base58Check address:
//
// body = 0x41 || keccak256(X||Y)[12:] // last 20 bytes of the Keccak hash
// address = Base58( body || dsha256(body)[:4] )
//
// Note: TRON/Ethereum use *legacy* Keccak-256 (not the finalized SHA3-256).
func PubKeyToTronAddress(uncompressed []byte) string {
h := sha3.NewLegacyKeccak256()
h.Write(uncompressed[1:]) // drop the 0x04 prefix; hash the 64-byte X||Y
sum := h.Sum(nil)
body := append([]byte{tronAddrPrefix}, sum[12:]...) // 0x41 + last 20 bytes
return base58CheckEncode(body)
}
// base58CheckEncode appends a 4-byte double-SHA256 checksum and Base58-encodes.
// (TRON's version byte 0x41 is already inside input, so this is a plain
// checksum-append, not btcutil's version-byte CheckEncode.)
func base58CheckEncode(input []byte) string {
first := sha256.Sum256(input)
second := sha256.Sum256(first[:])
full := make([]byte, 0, len(input)+4)
full = append(full, input...)
full = append(full, second[:4]...)
return base58.Encode(full)
}