From 4393edf1d7d134d08f14695e64f3a6009bf630fc Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 9 Jul 2026 02:50:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(pay):=20pangolin-pay=20=E9=92=B1=E5=8C=85?= =?UTF-8?q?=E6=B4=BE=E7=94=9F=E6=A8=A1=E5=9D=97(#34/34A=20=C2=B7=20?= =?UTF-8?q?=E5=8A=A0=E5=AF=86=E8=B4=A7=E5=B8=81=E4=BA=A4=E6=98=93=E5=BC=95?= =?UTF-8?q?=E6=93=8E=20Phase=20B.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建独立 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 --- pay/go.mod | 18 ++++++ pay/go.sum | 33 ++++++++++ pay/internal/wallet/derive.go | 78 +++++++++++++++++++++++ pay/internal/wallet/derive_test.go | 99 ++++++++++++++++++++++++++++++ pay/internal/wallet/seed.go | 86 ++++++++++++++++++++++++++ 5 files changed, 314 insertions(+) create mode 100644 pay/go.mod create mode 100644 pay/go.sum create mode 100644 pay/internal/wallet/derive.go create mode 100644 pay/internal/wallet/derive_test.go create mode 100644 pay/internal/wallet/seed.go diff --git a/pay/go.mod b/pay/go.mod new file mode 100644 index 0000000..76ebf63 --- /dev/null +++ b/pay/go.mod @@ -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 +) diff --git a/pay/go.sum b/pay/go.sum new file mode 100644 index 0000000..ba6af72 --- /dev/null +++ b/pay/go.sum @@ -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= diff --git a/pay/internal/wallet/derive.go b/pay/internal/wallet/derive.go new file mode 100644 index 0000000..158f156 --- /dev/null +++ b/pay/internal/wallet/derive.go @@ -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/…// 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) +} diff --git a/pay/internal/wallet/derive_test.go b/pay/internal/wallet/derive_test.go new file mode 100644 index 0000000..4c6505e --- /dev/null +++ b/pay/internal/wallet/derive_test.go @@ -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 + } +) diff --git a/pay/internal/wallet/seed.go b/pay/internal/wallet/seed.go new file mode 100644 index 0000000..880472b --- /dev/null +++ b/pay/internal/wallet/seed.go @@ -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'/' 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'/'//, 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 +}