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>
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
||||
module github.com/wangjia/pangolin/pay
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/btcsuite/btcd v0.24.2
|
||||
github.com/btcsuite/btcd/btcutil v1.2.0
|
||||
github.com/tyler-smith/go-bip39 v1.1.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
)
|
||||
|
||||
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/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
)
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
|
||||
github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.5 h1:dpAlnAwmT1yIBm3exhT1/8iUSD98RDJM5vqJVQDQLiU=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.5/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ=
|
||||
github.com/btcsuite/btcd/btcutil v1.2.0 h1:p3+S2g3Q+7G5NOh4Ji+2UrBOrg5Z0Q4ykzShWG1Dhgs=
|
||||
github.com/btcsuite/btcd/btcutil v1.2.0/go.mod h1:/Taflm113pYjUpbWKKQEfa6XOtI/+WS8awxeMZpY75k=
|
||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
|
||||
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/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=
|
||||
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee h1:FPP9HDkBbPyniu+u7FHZg+kKFX1WW0gxOGteJ0h3AJk=
|
||||
github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee/go.mod h1:N6sz6HwJAenJ6d+/xmSl0ikfV05ZrVGmjt1ryy/WOtE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
|
||||
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testMnemonic is the canonical all-zero-entropy BIP39 test vector. The derived
|
||||
// TRON addresses logged by TestAccountXpubDeriveConsistency must match
|
||||
// iancoleman.io/bip39 (Coin = TRX, BIP44) — that manual comparison is Phase A.4
|
||||
// of the crypto-tx-engine plan (guards against a derivation mismatch that would
|
||||
// silently send funds to addresses we don't control).
|
||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
// TestAccountXpubDeriveConsistency proves the watcher's watch-only path
|
||||
// (xpub -> address) yields exactly the same address as the offline private path
|
||||
// (seed -> privkey -> pubkey -> address) for each index. That equality is what
|
||||
// guarantees every receiving address the watcher hands out is spendable by the
|
||||
// key we hold in cold storage.
|
||||
func TestAccountXpubDeriveConsistency(t *testing.T) {
|
||||
xpub, err := AccountXpubFromMnemonic(testMnemonic, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("account xpub: %v", err)
|
||||
}
|
||||
t.Logf("account xpub (m/44'/195'/0'): %s", xpub)
|
||||
|
||||
acct, err := AccountKeyFromMnemonic(testMnemonic, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("account key: %v", err)
|
||||
}
|
||||
|
||||
for i := uint32(0); i < 5; i++ {
|
||||
viaXpub, err := AddressFromAccountXpub(xpub, 0, i) // what the watcher does
|
||||
if err != nil {
|
||||
t.Fatalf("via xpub [%d]: %v", i, err)
|
||||
}
|
||||
|
||||
ck, err := acct.Derive(0)
|
||||
if err != nil {
|
||||
t.Fatalf("derive change: %v", err)
|
||||
}
|
||||
ak, err := ck.Derive(i)
|
||||
if err != nil {
|
||||
t.Fatalf("derive index %d: %v", i, err)
|
||||
}
|
||||
pub, err := ak.ECPubKey()
|
||||
if err != nil {
|
||||
t.Fatalf("ec pubkey: %v", err)
|
||||
}
|
||||
viaPriv := PubKeyToTronAddress(pub.SerializeUncompressed())
|
||||
|
||||
if viaXpub != viaPriv {
|
||||
t.Fatalf("index %d: xpub-derived %q != priv-derived %q", i, viaXpub, viaPriv)
|
||||
}
|
||||
if !strings.HasPrefix(viaXpub, "T") || len(viaXpub) != 34 {
|
||||
t.Fatalf("index %d: not a valid TRON address: %q", i, viaXpub)
|
||||
}
|
||||
t.Logf("m/44'/195'/0'/0/%d -> %s", i, viaXpub)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownVector locks the derivation to a golden result (filled from the run of
|
||||
// TestAccountXpubDeriveConsistency, then confirmed against iancoleman.io — A.4).
|
||||
// If this ever changes, the derivation implementation regressed.
|
||||
func TestKnownVector(t *testing.T) {
|
||||
if len(goldenAddrs) == 0 {
|
||||
t.Skip("golden vector not yet baked — run TestAccountXpubDeriveConsistency, confirm vs Ian Coleman, then fill goldenAddrs")
|
||||
}
|
||||
xpub, err := AccountXpubFromMnemonic(testMnemonic, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("xpub: %v", err)
|
||||
}
|
||||
if goldenXpub != "" && xpub != goldenXpub {
|
||||
t.Fatalf("account xpub changed:\n got %s\n want %s", xpub, goldenXpub)
|
||||
}
|
||||
for i, want := range goldenAddrs {
|
||||
got, err := AddressFromAccountXpub(xpub, 0, uint32(i))
|
||||
if err != nil {
|
||||
t.Fatalf("addr[%d]: %v", i, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("addr[%d]: got %s want %s", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Golden vector for the "abandon…about" test mnemonic, Coin=TRX, m/44'/195'/0'.
|
||||
// Locks the derivation against regression. ⚠️ MUST be confirmed once against
|
||||
// iancoleman.io/bip39 (Phase A.4) — self-consistency (TestAccountXpubDeriveConsistency)
|
||||
// proves the xpub and private paths agree, but only an independent tool proves
|
||||
// both aren't wrong the same way. If Ian Coleman disagrees, the impl has a bug.
|
||||
var (
|
||||
goldenXpub = "xpub6D1AabNHCupeiLM65ZR9UStMhJ1vCpyV4XbZdyhMZBiJXALQtmn9p42VTQckoHVn8WNqS7dqnJokZHAHcHGoaQgmv8D45oNUKx6DZMNZBCd"
|
||||
goldenAddrs = []string{
|
||||
"TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", // m/44'/195'/0'/0/0
|
||||
"TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK", // m/44'/195'/0'/0/1
|
||||
"TYJPRrdB5APNeRs4R7fYZSwW3TcrTKw2gx", // m/44'/195'/0'/0/2
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
bip39 "github.com/tyler-smith/go-bip39"
|
||||
)
|
||||
|
||||
// ⚠️ OFFLINE ONLY. Everything in this file touches the BIP39 seed / private keys.
|
||||
// It exists for (a) generating the account xpub to hand to the watcher, and
|
||||
// (b) deriving per-address private keys for offline sweep signing (Phase D).
|
||||
// It must NEVER be linked into or run on the internet-facing pangolin-pay
|
||||
// watcher — the hot service only ever handles the account xpub (see derive.go).
|
||||
|
||||
const (
|
||||
purposeBIP44 = 44
|
||||
coinTypeTRON = 195
|
||||
// hardenedOffset marks a derivation index as hardened (requires the private
|
||||
// key). BIP44's first three levels (purpose'/coin'/account') are hardened.
|
||||
hardenedOffset = hdkeychain.HardenedKeyStart // 0x80000000
|
||||
)
|
||||
|
||||
// AccountKeyFromMnemonic derives the account-level extended *private* key at
|
||||
// m/44'/195'/<account>' from a BIP39 mnemonic (+ optional passphrase).
|
||||
// OFFLINE ONLY.
|
||||
func AccountKeyFromMnemonic(mnemonic, passphrase string, account uint32) (*hdkeychain.ExtendedKey, error) {
|
||||
if !bip39.IsMnemonicValid(mnemonic) {
|
||||
return nil, fmt.Errorf("wallet: invalid BIP39 mnemonic (checksum/wordlist)")
|
||||
}
|
||||
seed := bip39.NewSeed(mnemonic, passphrase)
|
||||
master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wallet: master key: %w", err)
|
||||
}
|
||||
for _, step := range []uint32{
|
||||
hardenedOffset + purposeBIP44,
|
||||
hardenedOffset + coinTypeTRON,
|
||||
hardenedOffset + account,
|
||||
} {
|
||||
master, err = master.Derive(step)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wallet: derive account path: %w", err)
|
||||
}
|
||||
}
|
||||
return master, nil
|
||||
}
|
||||
|
||||
// AccountXpubFromMnemonic returns the account-level xpub string to hand to the
|
||||
// watcher. OFFLINE ONLY — run this once on the air-gapped machine, copy only the
|
||||
// returned xpub to the hot service.
|
||||
func AccountXpubFromMnemonic(mnemonic, passphrase string, account uint32) (string, error) {
|
||||
k, err := AccountKeyFromMnemonic(mnemonic, passphrase, account)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pub, err := k.Neuter() // strip the private key -> xpub
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: neuter: %w", err)
|
||||
}
|
||||
return pub.String(), nil
|
||||
}
|
||||
|
||||
// PrivKeyHexFromMnemonic derives the raw secp256k1 private key (hex) for the
|
||||
// address at m/44'/195'/<account>'/<change>/<index>, for offline sweep signing.
|
||||
// OFFLINE ONLY.
|
||||
func PrivKeyHexFromMnemonic(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)
|
||||
}
|
||||
priv, err := addrKey.ECPrivKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wallet: ec privkey: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", priv.Serialize()), nil
|
||||
}
|
||||
Reference in New Issue
Block a user