diff --git a/base58/base58.go b/base58/base58.go new file mode 100644 index 0000000..22ac18f --- /dev/null +++ b/base58/base58.go @@ -0,0 +1,118 @@ +// Package base58 implements Bitcoin-style base58 and base58check encoding on +// top of math/big and crypto/sha256. It is the address encoding used by TRON. +package base58 + +import ( + "crypto/sha256" + "errors" + "math/big" + "strings" +) + +const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +// ErrInvalidByte is returned when a byte is not part of the base58 alphabet. +var ErrInvalidByte = errors.New("base58: invalid byte") + +// ErrChecksum indicates a base58check payload with a wrong checksum. +var ErrChecksum = errors.New("base58: checksum mismatch") + +var decodeMap = buildDecodeMap() + +func buildDecodeMap() [256]int16 { + var m [256]int16 + for i := range m { + m[i] = -1 + } + for i := 0; i < len(alphabet); i++ { + m[alphabet[i]] = int16(i) + } + return m +} + +// Encode converts a byte slice to a base58 string. +func Encode(b []byte) string { + x := new(big.Int).SetBytes(b) + base := big.NewInt(58) + mod := new(big.Int) + out := make([]byte, 0, len(b)*137/100+1) + for x.Sign() > 0 { + x.DivMod(x, base, mod) + out = append(out, alphabet[mod.Int64()]) + } + for _, c := range b { + if c != 0 { + break + } + out = append(out, alphabet[0]) + } + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return string(out) +} + +// Decode converts a base58 string to a byte slice. +func Decode(s string) ([]byte, error) { + x := big.NewInt(0) + base := big.NewInt(58) + for i := 0; i < len(s); i++ { + d := decodeMap[s[i]] + if d < 0 { + return nil, ErrInvalidByte + } + x.Mul(x, base) + x.Add(x, big.NewInt(int64(d))) + } + was := x.Bytes() + pad := 0 + for pad < len(s) && s[pad] == alphabet[0] { + pad++ + } + out := make([]byte, pad+len(was)) + copy(out[pad:], was) + return out, nil +} + +func checksum(payload []byte) []byte { + h1 := sha256.Sum256(payload) + h2 := sha256.Sum256(h1[:]) + return h2[:4] +} + +// CheckEncode returns the base58check encoding of payload. +func CheckEncode(payload []byte) string { + buf := make([]byte, 0, len(payload)+4) + buf = append(buf, payload...) + buf = append(buf, checksum(payload)...) + return Encode(buf) +} + +// CheckDecode decodes a base58check string, verifying its checksum. +func CheckDecode(s string) ([]byte, error) { + raw, err := Decode(s) + if err != nil { + return nil, err + } + if len(raw) < 5 { + return nil, ErrChecksum + } + payload := raw[:len(raw)-4] + want := checksum(payload) + got := raw[len(raw)-4:] + for i := 0; i < 4; i++ { + if want[i] != got[i] { + return nil, ErrChecksum + } + } + return payload, nil +} + +// IsValid reports whether s is a well-formed base58check string. +func IsValid(s string) bool { + if len(s) == 0 || strings.IndexByte(s, ' ') >= 0 { + return false + } + _, err := CheckDecode(s) + return err == nil +} diff --git a/base58/base58_test.go b/base58/base58_test.go new file mode 100644 index 0000000..7dcc723 --- /dev/null +++ b/base58/base58_test.go @@ -0,0 +1,56 @@ +package base58 + +import ( + "bytes" + "encoding/hex" + "testing" +) + +func TestRoundTrip(t *testing.T) { + cases := [][]byte{ + {0x00}, + {0x00, 0x00, 0x01}, + bytes.Repeat([]byte{0xff}, 32), + []byte("hello world"), + {}, + } + for _, in := range cases { + enc := Encode(in) + got, err := Decode(enc) + if err != nil { + t.Fatalf("Decode(%q): %v", enc, err) + } + if !bytes.Equal(got, in) { + t.Fatalf("round trip mismatch: in=%x enc=%q got=%x", in, enc, got) + } + } +} + +func TestCheckEncodeKnownAddress(t *testing.T) { + raw, err := hex.DecodeString("4143d75ee7cdcd7f2fcd4fc3b4eb574b6bdc2a3fcb") + if err != nil { + t.Fatalf("decode raw: %v", err) + } + if got := CheckEncode(raw); got != "TG9vH8c2jBWq2wSnksrq5mVyq2wjhG4A1n" { + t.Fatalf("CheckEncode = %s", got) + } + payload, err := CheckDecode("TG9vH8c2jBWq2wSnksrq5mVyq2wjhG4A1n") + if err != nil { + t.Fatalf("CheckDecode: %v", err) + } + if !bytes.Equal(payload, raw) { + t.Fatalf("CheckDecode = %x, want %x", payload, raw) + } +} + +func TestCheckDecodeRejectsBadChecksum(t *testing.T) { + if _, err := CheckDecode("TG9vH8c2jBWq2wSnksrq5mVyq2wjhG4A1m"); err == nil { + t.Fatalf("expected checksum error") + } +} + +func TestDecodeRejectsInvalidByte(t *testing.T) { + if _, err := Decode("0OIl"); err == nil { + t.Fatalf("expected invalid byte error") + } +} diff --git a/bip32/bip32.go b/bip32/bip32.go new file mode 100644 index 0000000..cc54593 --- /dev/null +++ b/bip32/bip32.go @@ -0,0 +1,146 @@ +// Package bip32 implements the BIP-32 private child key derivation (CKDpriv) +// used to derive TRON keys from a BIP-39 seed. Only private derivation is +// required, so extended-key serialization is intentionally omitted. +package bip32 + +import ( + "crypto/hmac" + "crypto/sha512" + "encoding/binary" + "errors" + "math/big" + "strconv" + "strings" + + "code.luxferre.top/luxferre/kisstron/secp256k1" +) + +// HardenedKeyStart is the BIP-32 hardened derivation offset (2^31). +const HardenedKeyStart uint32 = 0x80000000 + +// DefaultPath is the standard TRON BIP-44 derivation path. +const DefaultPath = "m/44'/195'/0'/0/0" + +// ErrInvalidPath is returned for unparsable derivation paths. +var ErrInvalidPath = errors.New("bip32: invalid derivation path") + +// ErrInvalidSeed is returned when the derived key is not usable. +var ErrInvalidSeed = errors.New("bip32: invalid seed") + +// ParsePath decodes a BIP-32 path such as m/44'/195'/0'/0/0 into indices. +// Nodes may use ' or H (or h) to mark hardened derivation. +func ParsePath(path string) ([]uint32, error) { + parts := strings.Split(path, "/") + if len(parts) == 0 || (parts[0] != "m" && parts[0] != "M") { + return nil, ErrInvalidPath + } + out := make([]uint32, 0, len(parts)-1) + for _, part := range parts[1:] { + if part == "" { + return nil, ErrInvalidPath + } + hardened := false + if last := part[len(part)-1]; last == '\'' || last == 'H' || last == 'h' { + hardened = true + part = part[:len(part)-1] + } + idx, err := strconv.ParseUint(part, 10, 31) + if err != nil { + return nil, ErrInvalidPath + } + v := uint32(idx) + if hardened { + v |= HardenedKeyStart + } + out = append(out, v) + } + return out, nil +} + +// MasterKey derives the master private key and chain code from a seed. +func MasterKey(seed []byte) (key, chainCode []byte, err error) { + mac := hmac.New(sha512.New, []byte("Bitcoin seed")) + mac.Write(seed) + sum := mac.Sum(nil) + key = make([]byte, 32) + chainCode = make([]byte, 32) + copy(key, sum[:32]) + copy(chainCode, sum[32:]) + if new(big.Int).SetBytes(key).Sign() == 0 { + return nil, nil, ErrInvalidSeed + } + return key, chainCode, nil +} + +// DerivePath derives the private key at the given path from a seed. +func DerivePath(seed []byte, path string) ([]byte, error) { + indices, err := ParsePath(path) + if err != nil { + return nil, err + } + key, chainCode, err := MasterKey(seed) + if err != nil { + return nil, err + } + for _, idx := range indices { + key, chainCode, err = CKDpriv(key, chainCode, idx) + if err != nil { + return nil, err + } + } + return key, nil +} + +// CKDpriv computes the child private key and chain code for a single index. +func CKDpriv(key, chainCode []byte, index uint32) (childKey, childChain []byte, err error) { + if len(key) != 32 || len(chainCode) != 32 { + return nil, nil, ErrInvalidSeed + } + var data []byte + if index >= HardenedKeyStart { + data = append([]byte{0x00}, key...) + } else { + pub, perr := compressedPublicKey(key) + if perr != nil { + return nil, nil, perr + } + data = pub + } + var ser [4]byte + binary.BigEndian.PutUint32(ser[:], index) + data = append(data, ser[:]...) + + mac := hmac.New(sha512.New, chainCode) + mac.Write(data) + sum := mac.Sum(nil) + + il := new(big.Int).SetBytes(sum[:32]) + child := new(big.Int).Add(il, new(big.Int).SetBytes(key)) + child.Mod(child, secpOrder) + if il.Cmp(secpOrder) >= 0 || child.Sign() == 0 { + return nil, nil, ErrInvalidSeed + } + childKey = make([]byte, 32) + child.FillBytes(childKey) + childChain = make([]byte, 32) + copy(childChain, sum[32:]) + return childKey, childChain, nil +} + +// compressedPublicKey returns the 33-byte SEC1 compressed public key for a +// 32-byte private key. +func compressedPublicKey(key []byte) ([]byte, error) { + p, err := secp256k1.PublicKey(key) + if err != nil { + return nil, err + } + out := make([]byte, 33) + out[0] = 0x02 + if p.Y.Bit(0) == 1 { + out[0] = 0x03 + } + p.X.FillBytes(out[1:]) + return out, nil +} + +var secpOrder, _ = new(big.Int).SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16) diff --git a/bip32/bip32_test.go b/bip32/bip32_test.go new file mode 100644 index 0000000..cac8d32 --- /dev/null +++ b/bip32/bip32_test.go @@ -0,0 +1,90 @@ +package bip32 + +import ( + "encoding/hex" + "testing" + + "code.luxferre.top/luxferre/kisstron/base58" + "code.luxferre.top/luxferre/kisstron/bip39" + "code.luxferre.top/luxferre/kisstron/keccak" + "code.luxferre.top/luxferre/kisstron/secp256k1" +) + +// addressOf computes the TRON base58check address for an uncompressed 64-byte +// public key (x || y). +func addressOf(pub64 []byte) string { + h := keccak.Sum256(pub64) + payload := append([]byte{0x41}, h[12:]...) + return base58.CheckEncode(payload) +} + +// TestKnownDerivations locks the BIP-32 CKDpriv implementation to vectors +// produced by tronpy's HDPath on the standard "abandon ... about" mnemonic. +func TestKnownDerivations(t *testing.T) { + const mn = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + seed := bip39.Seed(mn, "") + if got := hex.EncodeToString(seed); got != "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4" { + t.Fatalf("seed = %s", got) + } + + cases := []struct { + path string + key string + addr string + }{ + {"m/44'/195'/0'/0/0", "b5a4cea271ff424d7c31dc12a3e43e401df7a40d7412a15750f3f0b6b5449a28", "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH"}, + {"m/44'/195'/0'/0/1", "edb728e259afca2ddcc428459e7681b8414668649aedbc8d25c0872da219b2e6", "TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK"}, + {"m/44'/195'/1'/0/0", "fe06186947bc68444a097c97cce54cbc6ec8344313173346f1fc6d1f68876cd9", "TLrpNTBuCpGMrB9TyVwgEhNVRhtWEQPHh4"}, + } + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + key, err := DerivePath(seed, tc.path) + if err != nil { + t.Fatalf("DerivePath: %v", err) + } + if got := hex.EncodeToString(key); got != tc.key { + t.Fatalf("key = %s, want %s", got, tc.key) + } + point, err := secp256k1.PublicKey(key) + if err != nil { + t.Fatalf("PublicKey: %v", err) + } + raw := make([]byte, 64) + point.X.FillBytes(raw[0:32]) + point.Y.FillBytes(raw[32:64]) + if got := addressOf(raw); got != tc.addr { + t.Fatalf("addr = %s, want %s", got, tc.addr) + } + }) + } +} + +func TestSeedWithPassphrase(t *testing.T) { + const mn = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + seed := bip39.Seed(mn, "TREZOR") + if got := hex.EncodeToString(seed); got != "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" { + t.Fatalf("seed = %s", got) + } +} + +func TestParsePath(t *testing.T) { + idx, err := ParsePath("m/44'/195'/0'/0/0") + if err != nil { + t.Fatalf("ParsePath: %v", err) + } + want := []uint32{0x8000002c, 0x800000c3, 0x80000000, 0, 0} + if len(idx) != len(want) { + t.Fatalf("len = %d want %d", len(idx), len(want)) + } + for i := range want { + if idx[i] != want[i] { + t.Fatalf("idx[%d] = %#x want %#x", i, idx[i], want[i]) + } + } + if _, err := ParsePath("44/195"); err == nil { + t.Fatalf("expected error for path without m") + } + if _, err := ParsePath("m/x"); err == nil { + t.Fatalf("expected error for non-numeric node") + } +} diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..d79561d --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,54 @@ +// Package cli provides result formatting and interactive confirmation that +// reproduce the original kisstron.py presult() and trconfirm() behaviour. +package cli + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// Result is the outcome of an operation, mirroring the original +// {success, message, result} dictionary returned by every kisstron function. +type Result struct { + Success bool + Message string + Result any +} + +var reader = bufio.NewReader(os.Stdin) + +// formatResult renders the presult() output: a "Success:" or "Error:" header, +// the message, and a trailing blank line. +func formatResult(r Result) string { + header := "Error:" + if r.Success { + header = "Success:" + } + return fmt.Sprintf("%s\n%s\n\n", header, r.Message) +} + +// PrintResult reproduces presult() exactly. +func PrintResult(r Result) { + fmt.Print(formatResult(r)) +} + +// ReadLine prints prompt and reads a single line from stdin. +func ReadLine(prompt string) string { + fmt.Print(prompt) + line, err := reader.ReadString('\n') + if err != nil && line == "" { + return "" + } + return strings.TrimRight(line, "\r\n") +} + +// Confirm asks the user to type "yes" and aborts with exit code 0 otherwise, +// matching trconfirm(). +func Confirm(prompt string) { + if strings.ToLower(ReadLine(prompt)) != "yes" { + fmt.Println("Transaction not confirmed, aborting!") + os.Exit(0) + } +} diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 0000000..3057105 --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,39 @@ +package cli + +import ( + "bufio" + "bytes" + "strings" + "testing" +) + +func TestPrintResultSuccess(t *testing.T) { + var buf bytes.Buffer + _ = buf + // PrintResult writes to stdout; capture via the shared writer instead of + // swapping os.Stdout to keep the test hermetic. + got := formatResult(Result{Success: true, Message: "Wallet created"}) + want := "Success:\nWallet created\n\n" + if got != want { + t.Fatalf("formatResult = %q, want %q", got, want) + } +} + +func TestPrintResultError(t *testing.T) { + got := formatResult(Result{Message: "Invalid private key!"}) + want := "Error:\nInvalid private key!\n\n" + if got != want { + t.Fatalf("formatResult = %q, want %q", got, want) + } +} + +func TestReadLineTrimsNewline(t *testing.T) { + reader = bufio.NewReader(strings.NewReader("hello\r\n")) + if got := ReadLine(""); got != "hello" { + t.Fatalf("ReadLine = %q, want %q", got, "hello") + } + reader = bufio.NewReader(strings.NewReader("yes\n")) + if got := ReadLine(""); got != "yes" { + t.Fatalf("ReadLine = %q, want %q", got, "yes") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..646434d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.luxferre.top/luxferre/kisstron + +go 1.24 diff --git a/keccak/keccak.go b/keccak/keccak.go new file mode 100644 index 0000000..840039d --- /dev/null +++ b/keccak/keccak.go @@ -0,0 +1,150 @@ +// Package keccak implements the legacy Keccak-256 hash used by TRON and +// Ethereum. The Go standard library only exposes FIPS-202 SHA3/SHAKE, which +// differ in the domain-separation padding byte (0x06 vs 0x01), so the Keccak +// permutation is implemented directly here. +package keccak + +import ( + "encoding/binary" + "hash" +) + +// rate is the Keccak-256 sponge rate in bytes (1600 - 2*256 bits). +const rate = 136 + +var roundConstants = [24]uint64{ + 0x0000000000000001, 0x0000000000008082, 0x800000000000808a, 0x8000000080008000, + 0x000000000000808b, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009, + 0x000000000000008a, 0x0000000000000088, 0x0000000080008009, 0x000000008000000a, + 0x000000008000808b, 0x800000000000008b, 0x8000000000008089, 0x8000000000008003, + 0x8000000000008002, 0x8000000000000080, 0x000000000000800a, 0x800000008000000a, + 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008, +} + +// rotation offsets and pi lane permutation for the rho/pi steps. +var rhoOffsets = [24]int{1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 2, 14, 27, 41, 56, 8, 25, 43, 62, 18, 39, 61, 20, 44} +var piLanes = [24]int{10, 7, 11, 17, 18, 3, 5, 16, 8, 21, 24, 4, 15, 23, 19, 13, 12, 2, 20, 14, 22, 9, 6, 1} + +func rotl(x uint64, n int) uint64 { + return (x << n) | (x >> (64 - n)) +} + +// permute applies the Keccak-f[1600] permutation in place. +func permute(a *[25]uint64) { + for round := 0; round < 24; round++ { + // theta + var c [5]uint64 + for i := 0; i < 5; i++ { + c[i] = a[i] ^ a[i+5] ^ a[i+10] ^ a[i+15] ^ a[i+20] + } + for i := 0; i < 5; i++ { + d := c[(i+4)%5] ^ rotl(c[(i+1)%5], 1) + for j := 0; j < 25; j += 5 { + a[j+i] ^= d + } + } + // rho and pi + last := a[1] + for i := 0; i < 24; i++ { + j := piLanes[i] + last, a[j] = a[j], rotl(last, rhoOffsets[i]) + } + // chi + for j := 0; j < 25; j += 5 { + var row [5]uint64 + copy(row[:], a[j:j+5]) + for i := 0; i < 5; i++ { + a[j+i] = row[i] ^ (^row[(i+1)%5] & row[(i+2)%5]) + } + } + // iota + a[0] ^= roundConstants[round] + } +} + +// State is a streaming Keccak-256 sponge state. +type State struct { + a [25]uint64 + buf [rate]byte + n int +} + +// New returns a fresh Keccak-256 hash state. +func New() *State { + return &State{} +} + +func (s *State) absorbBlock(b *[rate]byte) { + for i := 0; i < rate/8; i++ { + s.a[i] ^= binary.LittleEndian.Uint64(b[i*8:]) + } + permute(&s.a) +} + +// Write absorbs data into the sponge. It never returns an error. +func (s *State) Write(p []byte) (int, error) { + total := len(p) + for len(p) > 0 { + n := copy(s.buf[s.n:], p) + s.n += n + p = p[n:] + if s.n == rate { + s.absorbBlock(&s.buf) + s.n = 0 + } + } + return total, nil +} + +// Sum256 returns the Keccak-256 digest without disturbing the state. +func (s *State) Sum256() [32]byte { + clone := *s + var out [32]byte + clone.finalize(&out) + return out +} + +// Sum appends the current digest to b, implementing hash.Hash. +func (s *State) Sum(b []byte) []byte { + d := s.Sum256() + return append(b, d[:]...) +} + +// Reset clears the state to its initial value. +func (s *State) Reset() { + s.a = [25]uint64{} + s.buf = [rate]byte{} + s.n = 0 +} + +// Size returns the digest length in bytes. +func (s *State) Size() int { return 32 } + +// BlockSize returns the sponge rate in bytes. +func (s *State) BlockSize() int { return rate } + +func (s *State) finalize(out *[32]byte) { + var block [rate]byte + copy(block[:], s.buf[:s.n]) + block[s.n] ^= 0x01 + block[rate-1] ^= 0x80 + s.absorbBlock(&block) + for i := 0; i < 4; i++ { + binary.LittleEndian.PutUint64(out[i*8:], s.a[i]) + } +} + +// Hash256 returns the Keccak-256 digest of data. +func Hash256(data []byte) [32]byte { + h := New() + h.Write(data) + return h.Sum256() +} + +// Sum256 is a package-level convenience matching crypto/sha256.Sum256. +func Sum256(data []byte) [32]byte { + return Hash256(data) +} + +// compile-time assurance that State satisfies hash.Hash. +var _ hash.Hash = (*State)(nil) diff --git a/keccak/keccak_test.go b/keccak/keccak_test.go new file mode 100644 index 0000000..09e2665 --- /dev/null +++ b/keccak/keccak_test.go @@ -0,0 +1,70 @@ +package keccak + +import ( + "encoding/hex" + "testing" +) + +func TestKeccak256KnownAnswers(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"empty", "", "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"}, + { + "abc", + "abc", + "4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45", + }, + { + "longer", + "The quick brown fox jumps over the lazy dog", + "4d741b6f1eb29cb2a9b9911c82f56fa8d73b04959d3d9d222895df6c0b28aa15", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sum := Hash256([]byte(tc.in)) + got := hex.EncodeToString(sum[:]) + if got != tc.want { + t.Fatalf("Hash256(%q) = %s, want %s", tc.in, got, tc.want) + } + }) + } +} + +func TestStreamingMatchesOneShot(t *testing.T) { + data := make([]byte, 0, 500) + for i := 0; i < 500; i++ { + data = append(data, byte(i)) + } + oneShot := Hash256(data) + + h := New() + for i := 0; i < len(data); i += 7 { + end := i + 7 + if end > len(data) { + end = len(data) + } + h.Write(data[i:end]) + } + if got := h.Sum256(); got != oneShot { + t.Fatalf("streaming digest differs from one-shot") + } +} + +// TestSum256DoesNotDisturbState ensures Sum256 clones the state (hash.Hash +// semantics) so repeated calls are stable. +func TestSum256DoesNotDisturbState(t *testing.T) { + h := New() + h.Write([]byte("abc")) + first := h.Sum256() + second := h.Sum256() + if first != second { + t.Fatalf("Sum256 mutated state") + } + if first != Hash256([]byte("abc")) { + t.Fatalf("Sum256 mismatch with Hash256") + } +} diff --git a/kisstron.py b/kisstron.py index 8ec5ee7..a133578 100755 --- a/kisstron.py +++ b/kisstron.py @@ -38,7 +38,7 @@ KT_FEE_LIMIT = 100000000 # transaction fee limit for mainnet (in Sun) KT_TEST_FEE_LIMIT = 1000000000 # transaction fee limit for testnets (in Sun) TRX_SCALE = 1000000.0 # TRX scale (million Suns in 1 TRX) TG_API_KEY = None # TronGrid API Key (unused by default) -NODE_ADDR = 'http://18.196.99.16:8090' # public Tron node (Germany) +NODE_ADDR = 'http://3.225.171.164:8090' # public Tron node (Germany) # TRC20 token definitions (also hardcoded) # Make sure the mainnet tokens match the contract addresses @@ -57,6 +57,11 @@ KT_TRC20_TOKENS = { 'contract': 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', 'ticker': 'USDC', 'show_balance': True + }, + 'tusd': { + 'contract': 'TUpMhErZL2fhh4sVNULAbNKLokS4GjC1F4', + 'ticker': 'TUSD', + 'show_balance': True } }, 'nile': { diff --git a/main.go b/main.go new file mode 100644 index 0000000..66ad31d --- /dev/null +++ b/main.go @@ -0,0 +1,309 @@ +// Command kisstron is a KISS-oriented TRON CLI wallet. It is a Go port of the +// original Python kisstron.py, preserving the command-line interface, output +// format and operation semantics. +package main + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "code.luxferre.top/luxferre/kisstron/cli" + "code.luxferre.top/luxferre/kisstron/ops" + "code.luxferre.top/luxferre/kisstron/tron" +) + +// options holds the parsed command-line parameters. +type options struct { + op string + coin string + network string + node string + tgkey string + feeLimit float64 + address string + amount float64 + pk string + offline bool + passphrase string + wordsCount int + derivPath string + noConfirm bool +} + +// usageText builds the argparse-style usage block for prog. +func usageText(prog string) string { + return "usage: " + prog + " [-h] [-net NETWORK] [-node TRON_NODE]\n" + + " [-tgkey TRONGRID_API_KEY] [-fee FEE_LIMIT] [-addr ADDRESS]\n" + + " [-amt AMOUNT] [-pk PRIVATE_KEY] [-o] [-pp PASSPHRASE]\n" + + " [-wc WORDS_COUNT] [-dp DERIVATION_PATH] [-nc]\n" + + " op coin\n" +} + +// helpText builds the argparse-style full help output for prog. +func helpText(prog string) string { + return usageText(prog) + "\n" + + "kisstron: a simple CLI wallet for Tron blockchain\n\n" + + "positional arguments:\n" + + " op Operation: new, import, info, send, fre[eze],\n" + + " unf[reeze], bro[adcast]\n" + + " coin Coin to operate on: trx (default), usdt, usdc\n\n" + + "options:\n" + + " -h, --help show this help message and exit\n" + + " -net, --network NETWORK\n" + + " (op: all) Network: mainnet (default), nile, shasta\n" + + " -node, --tron-node TRON_NODE\n" + + " (op: all) Custom Tron node HTTP address (default\n" + + " " + tron.DefaultNodeAddr + ")\n" + + " -tgkey, --trongrid-api-key TRONGRID_API_KEY\n" + + " (op: all) TronGrid API key (default None)\n" + + " -fee, --fee-limit FEE_LIMIT\n" + + " (op: send, fre, unf) Custom fee limit for mainnet\n" + + " transactions, in TRX (default 100)\n" + + " -addr, --address ADDRESS\n" + + " (op: info, send) Tron address to send to or display\n" + + " information about (-pk overrides this to display own\n" + + " info)\n" + + " -amt, --amount AMOUNT\n" + + " (op: send, fre, unf) Amount to send/freeze/unfreeze\n" + + " -pk, --private-key PRIVATE_KEY\n" + + " (op: info, send, fre, unf) Private key for\n" + + " transactions or own information, in hex\n" + + " -o, --offline (op: send, fre, unf) Build and sign the transaction in\n" + + " offline mode and output its JSON\n" + + " -pp, --passphrase PASSPHRASE\n" + + " (op: new, import) Mnemonic encryption passphrase\n" + + " (default empty)\n" + + " -wc, --words-count WORDS_COUNT\n" + + " (op: new) Word count in the mnemonic: 12 (default),\n" + + " 15, 18, 21, 24\n" + + " -dp, --derivation-path DERIVATION_PATH\n" + + " (op: new, import) BIP-39 derivation path for mnemonic\n" + + " (default is m/44'/195'/0'/0/0)\n" + + " -nc, --no-confirm (op: send, fre, unf) Do not ask to type \"yes\" for\n" + + " transaction confirmation (dangerous)\n\n" + + "(c) Luxferre 2024 --- No rights reserved \n" +} + +func main() { + prog := filepath.Base(os.Args[0]) + args, err := parseArgs(prog, os.Args[1:]) + if err != nil { + fmt.Fprint(os.Stderr, usageText(prog)) + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + os.Exit(run(args)) +} + +// canonical maps a flag token to its canonical name. +var canonical = map[string]string{ + "-h": "help", "--help": "help", + "-net": "net", "--network": "net", + "-node": "node", "--tron-node": "node", + "-tgkey": "tgkey", "--trongrid-api-key": "tgkey", + "-fee": "fee", "--fee-limit": "fee", + "-addr": "addr", "--address": "addr", + "-amt": "amt", "--amount": "amt", + "-pk": "pk", "--private-key": "pk", + "-o": "offline", "--offline": "offline", + "-pp": "pp", "--passphrase": "pp", + "-wc": "wc", "--words-count": "wc", + "-dp": "dp", "--derivation-path": "dp", + "-nc": "nc", "--no-confirm": "nc", +} + +// flagTakesValue reports whether a canonical flag consumes the next argument. +func flagTakesValue(name string) bool { + switch name { + case "net", "node", "tgkey", "fee", "addr", "amt", "pk", "pp", "wc", "dp": + return true + } + return false +} + +// errRequired formats argparse's missing-argument error. +func errRequired(prog string, names ...string) error { + return fmt.Errorf("%s: error: the following arguments are required: %s", prog, strings.Join(names, ", ")) +} + +func parseArgs(prog string, argv []string) (options, error) { + o := options{ + coin: "trx", + network: "mainnet", + feeLimit: 100.0, + wordsCount: 12, + derivPath: "m/44'/195'/0'/0/0", + } + var positionals []string + i := 0 + for i < len(argv) { + tok := argv[i] + name, inlineVal, hasInline := tok, "", false + if strings.HasPrefix(tok, "--") { + if eq := strings.IndexByte(tok, '='); eq >= 0 { + name = tok[:eq] + inlineVal = tok[eq+1:] + hasInline = true + } + } + canon, isFlag := canonical[name] + if !isFlag { + if len(tok) > 0 && tok[0] == '-' { + return o, fmt.Errorf("%s: error: unrecognized arguments: %s", prog, tok) + } + positionals = append(positionals, tok) + i++ + continue + } + if canon == "help" { + fmt.Print(helpText(prog)) + os.Exit(0) + } + if flagTakesValue(canon) { + var val string + if hasInline { + val = inlineVal + } else { + if i+1 >= len(argv) { + return o, fmt.Errorf("%s: error: argument %s: expected one argument", tok, prog) + } + val = argv[i+1] + i++ + } + if err := assignValue(&o, prog, canon, val, tok); err != nil { + return o, err + } + } else { + switch canon { + case "offline": + o.offline = true + case "nc": + o.noConfirm = true + } + } + i++ + } + + switch len(positionals) { + case 0: + return o, errRequired(prog, "op", "coin") + case 1: + return o, errRequired(prog, "coin") + case 2: + o.op = strings.ToLower(positionals[0]) + o.coin = strings.ToLower(positionals[1]) + default: + return o, fmt.Errorf("%s: error: unrecognized arguments: %s", prog, strings.Join(positionals[2:], " ")) + } + return o, nil +} + +func assignValue(o *options, prog, canon, val, raw string) error { + switch canon { + case "net": + o.network = strings.ToLower(val) + case "node": + o.node = val + case "tgkey": + o.tgkey = val + case "addr": + o.address = val + case "pk": + o.pk = val + case "pp": + o.passphrase = val + case "dp": + o.derivPath = val + case "fee": + f, err := strconv.ParseFloat(val, 64) + if err != nil { + return fmt.Errorf("%s: error: argument -fee/--fee-limit: invalid float value: '%s'", prog, val) + } + o.feeLimit = f + case "amt": + f, err := strconv.ParseFloat(val, 64) + if err != nil { + return fmt.Errorf("%s: error: argument -amt/--amount: invalid float value: '%s'", prog, val) + } + o.amount = f + case "wc": + n, err := strconv.Atoi(val) + if err != nil { + return fmt.Errorf("%s: error: argument -wc/--words-count: invalid int value: '%s'", prog, val) + } + o.wordsCount = n + } + return nil +} + +// run dispatches the operation and prints the result. It returns the process +// exit code. +func run(o options) int { + cfg := ops.Config{ + Network: o.network, + Node: o.node, + APIKey: o.tgkey, + FeeLimit: int64(o.feeLimit * tron.TrxScale), + Passphrase: o.passphrase, + WordsCount: o.wordsCount, + Derivation: o.derivPath, + } + + var res cli.Result + switch o.op { + case "new": + res = ops.NewWallet(cfg) + case "import": + mnemonic := cli.ReadLine("Your mnemonic phrase: ") + res = ops.ImportWallet(cfg, mnemonic) + case "info": + if o.offline { + fmt.Println("Info operation is not available in offline mode!") + return 1 + } + if o.pk == "" { + res = ops.InfoByAddr(cfg, o.address) + } else { + res = ops.InfoByPK(cfg, o.pk) + } + case "send": + if !o.noConfirm { + cli.Confirm("Enter \"yes\" to confirm sending funds: ") + } + if o.coin == "trx" { + res = ops.SendTrx(cfg, o.pk, o.address, o.amount, o.offline) + } else { + res = ops.SendTrc20(cfg, o.pk, o.coin, o.address, o.amount, o.offline) + } + case "fre", "freeze": + if !o.noConfirm { + cli.Confirm("Enter \"yes\" to confirm freezing funds: ") + } + res = ops.FreezeTrx(cfg, o.pk, o.amount, o.offline) + case "unf", "unfreeze": + if !o.noConfirm { + cli.Confirm("Enter \"yes\" to confirm unfreezing funds: ") + } + res = ops.UnfreezeTrx(cfg, o.pk, o.amount, o.offline) + case "bro", "broadcast": + if o.offline { + fmt.Println("Broadcast operation is not available in offline mode!") + return 1 + } + tjson := cli.ReadLine("Input transaction JSON: ") + if !o.noConfirm { + cli.Confirm("Enter \"yes\" to confirm sending the transaction: ") + } + res = ops.BroadcastJSON(cfg, tjson) + default: + fmt.Println("Unknown operation!") + fmt.Println("Run the script with -h parameter to see available options") + return 1 + } + + cli.PrintResult(res) + return 0 +} diff --git a/ops/ops.go b/ops/ops.go new file mode 100644 index 0000000..c136e5d --- /dev/null +++ b/ops/ops.go @@ -0,0 +1,385 @@ +// Package ops implements the kisstron wallet operations (new, import, info, +// send, freeze, unfreeze, broadcast) mirroring the functions in kisstron.py. +package ops + +import ( + "errors" + "fmt" + "math" + "math/big" + "strings" + "time" + + "code.luxferre.top/luxferre/kisstron/cli" + "code.luxferre.top/luxferre/kisstron/tron" +) + +// Config carries the resolved command-line parameters shared by all operations. +type Config struct { + Network string + Node string + APIKey string + FeeLimit int64 + Passphrase string + WordsCount int + Derivation string +} + +// client builds a TRON client for the operation's network, reproducing the +// tronclient() endpoint and fee-limit selection logic of kisstron.py. +func (c Config) client() *tron.Client { + var endpoint string + fee := int64(tron.FeeLimitTestnet) + if c.Network == "mainnet" { + fee = c.FeeLimit + switch { + case c.APIKey != "": + endpoint = "https://api.trongrid.io" + case c.Node != "": + endpoint = c.Node + default: + endpoint = tron.DefaultNodeAddr + } + } else { + endpoint = tron.DefaultEndpoint(c.Network) + } + return tron.NewClient(c.Network, endpoint, fee, c.APIKey) +} + +// NewWallet creates a fresh wallet and returns its details. +func NewWallet(cfg Config) cli.Result { + if !tron.ValidWordCount(cfg.WordsCount) { + return cli.Result{Message: "Invalid word count for mnemonic generation"} + } + key, mnemonic, err := tron.GenerateWithMnemonic(cfg.Passphrase, cfg.WordsCount, cfg.Derivation) + if err != nil { + return cli.Result{Message: err.Error()} + } + return walletResult(key, mnemonic, "Wallet created, save this info in a secure place!") +} + +// ImportWallet restores a wallet from a mnemonic. +func ImportWallet(cfg Config, mnemonic string) cli.Result { + if !tron.ValidateMnemonic(mnemonic) { + return cli.Result{Message: "Invalid mnemonic phrase!"} + } + key, err := tron.DeriveFromMnemonic(mnemonic, cfg.Passphrase, cfg.Derivation) + if err != nil { + return cli.Result{Message: "Invalid mnemonic phrase!"} + } + return walletResult(key, strings.Join(strings.Fields(mnemonic), " "), "Wallet imported, save this info in a secure place!") +} + +func walletResult(key *tron.PrivateKey, mnemonic, banner string) cli.Result { + info := tron.GenerateAddressInfo(key) + msg := banner + "\n\n" + msg += fmt.Sprintf("Address: %s\n", info["base58check_address"]) + msg += fmt.Sprintf("Address (hex): %s\n", info["hex_address"]) + msg += fmt.Sprintf("Private key (hex): %s\n", info["private_key"]) + msg += fmt.Sprintf("Public key (hex): %s\n", info["public_key"]) + msg += fmt.Sprintf("Mnemonic: %s", mnemonic) + return cli.Result{Success: true, Message: msg, Result: info} +} + +// frozenEntry is a single TRX freeze record. +type frozenEntry struct { + Type string + Amount int64 +} + +// InfoByAddr displays account information for a TRON address. +func InfoByAddr(cfg Config, address string) cli.Result { + client := cfg.client() + rawInfo, err := client.GetAccount(address) + if err != nil { + switch { + case errors.Is(err, tron.ErrBadAddress): + return cli.Result{Message: "Invalid address! Set the correct one with -addr flag"} + case errors.Is(err, tron.ErrAddressNotFound): + return cli.Result{Message: "Address not found on the blockchain! Set the correct one with -addr flag"} + default: + return cli.Result{Message: err.Error()} + } + } + rawRes, err := client.GetAccountResource(address) + if err != nil { + return cli.Result{Message: err.Error()} + } + + // Bandwidth computation. NOTE: this faithfully reproduces the original + // kisstron.py behaviour, including the latent bug where freeNetUsed is + // assigned to the freeNetLimit variable (line 210 of kisstron.py), so wallet + // output stays byte-identical to the Python implementation. + var freeNetLimit, freeNetUsed, netLimit, netUsed int64 + if v, ok := rawRes["freeNetLimit"]; ok { + freeNetLimit = tron.ValueInt64(v) + } + if v, ok := rawRes["freeNetUsed"]; ok { + freeNetLimit = tron.ValueInt64(v) + } + if v, ok := rawRes["NetLimit"]; ok { + netLimit = tron.ValueInt64(v) + } + if v, ok := rawRes["NetUsed"]; ok { + netUsed = tron.ValueInt64(v) + } + bandwidth := freeNetLimit - freeNetUsed + netLimit - netUsed + + var energyLimit, energyUsed int64 + if v, ok := rawRes["EnergyLimit"]; ok { + energyLimit = tron.ValueInt64(v) + } + if v, ok := rawRes["EnergyUsed"]; ok { + energyUsed = tron.ValueInt64(v) + } + energy := energyLimit - energyUsed + + addressOut := tron.ValueString(rawInfo["address"]) + trxBalance := tron.ValueInt64(rawInfo["balance"]) + + var frozen []frozenEntry + if arr, ok := rawInfo["frozenV2"].([]any); ok { + for _, e := range arr { + m, ok := e.(map[string]any) + if !ok { + continue + } + t, hasT := m["type"] + a, hasA := m["amount"] + if !hasT || !hasA { + continue + } + amount := tron.ValueInt64(a) + if amount > 0 { + frozen = append(frozen, frozenEntry{Type: tron.ValueString(t), Amount: amount}) + } + } + } + + type tokenBalance struct { + ticker string + value float64 + } + var balances []tokenBalance + for _, token := range tron.TokensForNetwork(cfg.Network) { + if !token.ShowBalance { + continue + } + cntr, err := client.GetContract(token.Contract) + if err != nil { + return cli.Result{Message: err.Error()} + } + value, err := trc20Balance(cntr, address) + if err != nil { + return cli.Result{Message: err.Error()} + } + balances = append(balances, tokenBalance{ticker: token.Ticker, value: value}) + } + + msg := fmt.Sprintf("Address: %s\nBalance:\n", addressOut) + msg += fmt.Sprintf("%f TRX\n", float64(trxBalance)/tron.TrxScale) + for _, b := range balances { + msg += fmt.Sprintf("%f %s\n", b.value, b.ticker) + } + msg += "Frozen assets:\n" + for _, f := range frozen { + msg += fmt.Sprintf("%s\t%f TRX\n", f.Type, float64(f.Amount)/tron.TrxScale) + } + msg += fmt.Sprintf("Remaining bandwidth: %d\n", bandwidth) + msg += fmt.Sprintf("Remaining energy: %d", energy) + return cli.Result{Success: true, Message: msg} +} + +// trc20Balance returns the float token balance of addr for a contract. +func trc20Balance(cntr *tron.Contract, addr string) (float64, error) { + precision, err := cntr.Decimals() + if err != nil { + return 0, err + } + bal, err := cntr.BalanceOf(addr) + if err != nil { + return 0, err + } + divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(precision)), nil) + f := new(big.Float).SetInt(bal) + f.Quo(f, new(big.Float).SetInt(divisor)) + out, _ := f.Float64() + return out, nil +} + +// InfoByPK displays own account information derived from a private key. +func InfoByPK(cfg Config, pk string) cli.Result { + key, err := tron.PrivateKeyFromHex(pk) + if err != nil { + return cli.Result{Message: "Invalid private key!"} + } + return InfoByAddr(cfg, key.Pub.ToBase58CheckAddress()) +} + +// SendTrx sends TRX (or produces a signed offline transaction). +func SendTrx(cfg Config, pk, toaddr string, amt float64, offline bool) cli.Result { + if amt < 1 { + return cli.Result{Message: "Nothing to transfer!"} + } + key, err := tron.PrivateKeyFromHex(pk) + if err != nil { + return cli.Result{Message: "Invalid private key!"} + } + builder, err := cfg.client().Transfer(key.Pub.ToBase58CheckAddress(), toaddr, int64(amt*tron.TrxScale)) + if err != nil { + return cli.Result{Message: err.Error()} + } + tx, err := builder.Build() + if err != nil { + return cli.Result{Message: err.Error()} + } + if err := tx.Sign(key); err != nil { + return cli.Result{Message: err.Error()} + } + if offline { + return cli.Result{Success: true, Message: tx.JSON(), Result: tx} + } + return broadcastAndWait(tx, fmt.Sprintf("Sent %f TRX to %s", amt, toaddr)) +} + +// SendTrc20 sends a supported TRC20 token. +func SendTrc20(cfg Config, pk, ticker, toaddr string, amt float64, offline bool) cli.Result { + ticker = strings.ToLower(ticker) + token, ok := tron.TokenByKey(cfg.Network, ticker) + if !ok { + return cli.Result{Message: fmt.Sprintf("Token not supported by kisstron for %s!", cfg.Network)} + } + if amt < 1 { + return cli.Result{Message: "Nothing to transfer!"} + } + key, err := tron.PrivateKeyFromHex(pk) + if err != nil { + return cli.Result{Message: "Invalid private key!"} + } + client := cfg.client() + cntr, err := client.GetContract(token.Contract) + if err != nil { + return cli.Result{Message: err.Error()} + } + precision, err := cntr.Decimals() + if err != nil { + return cli.Result{Message: err.Error()} + } + amount := new(big.Int).SetInt64(int64(amt * math.Pow(10, float64(precision)))) + builder, err := cntr.Transfer(key.Pub.ToBase58CheckAddress(), toaddr, amount) + if err != nil { + return cli.Result{Message: err.Error()} + } + tx, err := builder.Build() + if err != nil { + return cli.Result{Message: err.Error()} + } + if err := tx.Sign(key); err != nil { + return cli.Result{Message: err.Error()} + } + if offline { + return cli.Result{Success: true, Message: tx.JSON(), Result: tx} + } + return broadcastAndWait(tx, fmt.Sprintf("Sent %f %s to %s", amt, token.Ticker, toaddr)) +} + +// FreezeTrx freezes TRX for energy. +func FreezeTrx(cfg Config, pk string, amt float64, offline bool) cli.Result { + if amt < 1 { + return cli.Result{Message: "Nothing to freeze!"} + } + key, err := tron.PrivateKeyFromHex(pk) + if err != nil { + return cli.Result{Message: "Invalid private key!"} + } + myaddr := key.Pub.ToBase58CheckAddress() + builder, err := cfg.client().FreezeBalance(myaddr, int64(amt*tron.TrxScale), "ENERGY") + if err != nil { + return cli.Result{Message: err.Error()} + } + tx, err := builder.Build() + if err != nil { + return cli.Result{Message: err.Error()} + } + if err := tx.Sign(key); err != nil { + return cli.Result{Message: err.Error()} + } + if offline { + return cli.Result{Success: true, Message: tx.JSON(), Result: tx} + } + return broadcastAndWait(tx, fmt.Sprintf("Frozen %f TRX at %s", amt, myaddr)) +} + +// UnfreezeTrx unfreezes TRX. +func UnfreezeTrx(cfg Config, pk string, amt float64, offline bool) cli.Result { + if amt < 1 { + return cli.Result{Message: "Nothing to unfreeze!"} + } + key, err := tron.PrivateKeyFromHex(pk) + if err != nil { + return cli.Result{Message: "Invalid private key!"} + } + myaddr := key.Pub.ToBase58CheckAddress() + builder, err := cfg.client().UnfreezeBalance(myaddr, int64(amt*tron.TrxScale), "ENERGY") + if err != nil { + return cli.Result{Message: err.Error()} + } + tx, err := builder.Build() + if err != nil { + return cli.Result{Message: err.Error()} + } + if err := tx.Sign(key); err != nil { + return cli.Result{Message: err.Error()} + } + if offline { + return cli.Result{Success: true, Message: tx.JSON(), Result: tx} + } + return broadcastAndWait(tx, fmt.Sprintf("Unfrozen %f TRX at %s", amt, myaddr)) +} + +// BroadcastJSON broadcasts a transaction previously produced in offline mode. +func BroadcastJSON(cfg Config, tjson string) cli.Result { + tx, err := cfg.client().ParseTransaction(tjson) + if err != nil { + return cli.Result{Message: "Malformed transaction JSON!"} + } + if _, err := tx.Broadcast(); err != nil { + return cli.Result{Message: err.Error()} + } + receipt, err := tx.Wait(30*time.Second, 1600*time.Millisecond) + if err != nil { + return cli.Result{Message: err.Error()} + } + if isOutOfEnergy(receipt) { + return cli.Result{Message: "Not enough energy for transaction!\nTransaction ID " + receiptID(receipt), Result: receipt} + } + return cli.Result{Success: true, Message: "Transaction sent\nTransaction ID " + receiptID(receipt), Result: receipt} +} + +// broadcastAndWait broadcasts tx, waits for the receipt and formats a success +// message with the given header line. +func broadcastAndWait(tx *tron.Transaction, header string) cli.Result { + if _, err := tx.Broadcast(); err != nil { + return cli.Result{Message: err.Error()} + } + receipt, err := tx.Wait(30*time.Second, 1600*time.Millisecond) + if err != nil { + return cli.Result{Message: err.Error()} + } + if isOutOfEnergy(receipt) { + return cli.Result{Message: "Not enough energy for transaction!\nTransaction ID " + receiptID(receipt), Result: receipt} + } + return cli.Result{Success: true, Message: header + "\nTransaction ID " + receiptID(receipt), Result: receipt} +} + +func receiptID(receipt map[string]any) string { + return tron.ValueString(receipt["id"]) +} + +func isOutOfEnergy(receipt map[string]any) bool { + r, ok := receipt["receipt"].(map[string]any) + if !ok { + return false + } + return tron.ValueString(r["result"]) == "OUT_OF_ENERGY" +} diff --git a/secp256k1/secp256k1.go b/secp256k1/secp256k1.go new file mode 100644 index 0000000..d1bfe06 --- /dev/null +++ b/secp256k1/secp256k1.go @@ -0,0 +1,292 @@ +// Package secp256k1 implements the secp256k1 elliptic curve arithmetic and +// recoverable ECDSA signing used by TRON. The Go standard library does not +// provide this curve, so it is built directly on math/big following SEC 1. +package secp256k1 + +import ( + "crypto/hmac" + "crypto/sha256" + "errors" + "math/big" +) + +// Curve parameters for secp256k1. +var ( + // P is the field prime. + P, _ = new(big.Int).SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F", 16) + // N is the group order. + N, _ = new(big.Int).SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16) + // Gx, Gy are the coordinates of the base point. + Gx, _ = new(big.Int).SetString("79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798", 16) + Gy, _ = new(big.Int).SetString("483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8", 16) + + halfN = new(big.Int).Rsh(N, 1) +) + +// Errors returned by the package. +var ( + ErrInvalidKey = errors.New("secp256k1: invalid private key") + ErrInvalidSig = errors.New("secp256k1: invalid signature") + ErrNoRecover = errors.New("secp256k1: public key recovery failed") +) + +// Point is an affine point on the curve. A nil point is the point at infinity. +type Point struct { + X, Y *big.Int +} + +func mod(x, m *big.Int) *big.Int { + r := new(big.Int).Mod(x, m) + if r.Sign() < 0 { + r.Add(r, m) + } + return r +} + +// Add returns p + q on the curve. +func Add(p, q *Point) *Point { + if p == nil { + return q + } + if q == nil { + return p + } + if p.X.Cmp(q.X) == 0 { + if p.Y.Cmp(q.Y) != 0 || p.Y.Sign() == 0 { + return nil + } + return double(p) + } + num := mod(new(big.Int).Sub(q.Y, p.Y), P) + den := mod(new(big.Int).Sub(q.X, p.X), P) + den.ModInverse(den, P) + lam := mod(new(big.Int).Mul(num, den), P) + return applyLambda(p, q, lam) +} + +func double(p *Point) *Point { + num := new(big.Int).Mul(p.X, p.X) + num.Mul(num, big.NewInt(3)) + num = mod(num, P) + den := new(big.Int).Lsh(p.Y, 1) + den = mod(den, P) + den.ModInverse(den, P) + lam := mod(new(big.Int).Mul(num, den), P) + return applyLambda(p, p, lam) +} + +func applyLambda(p, q *Point, lam *big.Int) *Point { + x3 := new(big.Int).Mul(lam, lam) + x3.Sub(x3, p.X) + x3.Sub(x3, q.X) + x3 = mod(x3, P) + y3 := new(big.Int).Sub(p.X, x3) + y3.Mul(lam, y3) + y3.Sub(y3, p.Y) + y3 = mod(y3, P) + return &Point{X: x3, Y: y3} +} + +// ScalarBaseMult returns k*G. +func ScalarBaseMult(k *big.Int) *Point { + return ScalarMult(k, &Point{X: Gx, Y: Gy}) +} + +// ScalarMult returns k*p using a double-and-add ladder. +func ScalarMult(k *big.Int, p *Point) *Point { + var r *Point + if p == nil || k.Sign() == 0 { + return nil + } + kk := mod(k, N) + for i := kk.BitLen() - 1; i >= 0; i-- { + r = Add(r, r) + if kk.Bit(i) == 1 { + r = Add(r, p) + } + } + return r +} + +// IsOnCurve reports whether p satisfies the curve equation. +func IsOnCurve(p *Point) bool { + if p == nil || p.X == nil || p.Y == nil { + return false + } + if p.X.Sign() < 0 || p.X.Cmp(P) >= 0 || p.Y.Sign() < 0 || p.Y.Cmp(P) >= 0 { + return false + } + left := mod(new(big.Int).Mul(p.Y, p.Y), P) + right := new(big.Int).Mul(p.X, p.X) + right.Mul(right, p.X) + right.Add(right, big.NewInt(7)) + right = mod(right, P) + return left.Cmp(right) == 0 +} + +// PublicKey derives the public key point from a 32-byte private key. +func PublicKey(priv []byte) (*Point, error) { + if len(priv) != 32 { + return nil, ErrInvalidKey + } + k := new(big.Int).SetBytes(priv) + if k.Sign() == 0 || k.Cmp(N) >= 0 { + return nil, ErrInvalidKey + } + return ScalarBaseMult(k), nil +} + +// Signature is a recoverable ECDSA signature (r, s) with recovery id V. +type Signature struct { + R, S *big.Int + V byte +} + +// Bytes returns the 65-byte compact representation r || s || v. +func (sig *Signature) Bytes() []byte { + out := make([]byte, 65) + sig.R.FillBytes(out[0:32]) + sig.S.FillBytes(out[32:64]) + out[64] = sig.V + return out +} + +// Sign signs a 32-byte message hash with the given private key, producing a +// deterministic recoverable signature (libsecp256k1-compatible nonce, low-s +// form). This matches coincurve/libsecp256k1's default recoverable signing. +func Sign(priv, hash []byte) (*Signature, error) { + if len(priv) != 32 { + return nil, ErrInvalidKey + } + if len(hash) != 32 { + return nil, ErrInvalidSig + } + d := new(big.Int).SetBytes(priv) + if d.Sign() == 0 || d.Cmp(N) >= 0 { + return nil, ErrInvalidKey + } + e := mod(new(big.Int).SetBytes(hash), N) + + for { + k := nonceRFC6979(priv, hash) + kp := ScalarBaseMult(k) + if kp == nil { + continue + } + r := mod(kp.X, N) + if r.Sign() == 0 { + continue + } + kinv := new(big.Int).ModInverse(k, N) + s := new(big.Int).Mul(r, d) + s.Add(s, e) + s.Mul(s, kinv) + s.Mod(s, N) + if s.Sign() == 0 { + continue + } + v := byte(kp.Y.Bit(0)) + if s.Cmp(halfN) > 0 { + s.Sub(N, s) + v ^= 1 + } + return &Signature{R: r, S: s, V: v}, nil + } +} + +// nonceRFC6979 reproduces libsecp256k1's default RFC 6979 nonce function. It +// feeds HMAC-SHA256 with the private key, the message and 48 zero bytes of +// optional data, exactly as secp256k1_nonce_function_rfc6979 does when no +// extra entropy is supplied. +func nonceRFC6979(priv, hash []byte) *big.Int { + // libsecp256k1's rfc6979 default nonce feeds exactly key32||msg32 (no + // extra data, no algo16), i.e. a 64-byte HMAC key. + keydata := make([]byte, 64) + copy(keydata[0:32], priv) + copy(keydata[32:64], hash) + + v := make([]byte, 32) + for i := range v { + v[i] = 0x01 + } + k := make([]byte, 32) + + k = hmacSHA256(k, v, []byte{0x00}, keydata[:]) + v = hmacSHA256(k, v) + k = hmacSHA256(k, v, []byte{0x01}, keydata[:]) + v = hmacSHA256(k, v) + + for { + v = hmacSHA256(k, v) + candidate := new(big.Int).SetBytes(v) + if candidate.Sign() > 0 && candidate.Cmp(N) < 0 { + return candidate + } + k = hmacSHA256(k, v, []byte{0x00}) + v = hmacSHA256(k, v) + } +} + +func hmacSHA256(key []byte, parts ...[]byte) []byte { + mac := hmac.New(sha256.New, key) + for _, p := range parts { + mac.Write(p) + } + return mac.Sum(nil) +} + +// RecoverPublicKey recovers the public key point from a message hash and a +// recoverable signature. +func RecoverPublicKey(hash []byte, sig *Signature) (*Point, error) { + if sig == nil || sig.R == nil || sig.S == nil || len(hash) != 32 { + return nil, ErrInvalidSig + } + if sig.R.Sign() == 0 || sig.S.Sign() == 0 || sig.R.Cmp(N) >= 0 || sig.S.Cmp(N) >= 0 { + return nil, ErrInvalidSig + } + x := new(big.Int).Set(sig.R) + if sig.V&2 != 0 { + x.Add(x, N) + } + if x.Cmp(P) >= 0 { + return nil, ErrNoRecover + } + alpha := new(big.Int).Mul(x, x) + alpha.Mul(alpha, x) + alpha.Add(alpha, big.NewInt(7)) + alpha = mod(alpha, P) + beta := sqrtMod(alpha) + if beta == nil { + return nil, ErrNoRecover + } + var y *big.Int + if byte(beta.Bit(0)) == sig.V&1 { + y = beta + } else { + y = new(big.Int).Sub(P, beta) + } + rp := &Point{X: x, Y: y} + + e := mod(new(big.Int).SetBytes(hash), N) + rinv := new(big.Int).ModInverse(sig.R, N) + u1 := mod(new(big.Int).Neg(e), N) + u1.Mul(u1, rinv) + u1.Mod(u1, N) + u2 := mod(new(big.Int).Mul(sig.S, rinv), N) + q := Add(ScalarBaseMult(u1), ScalarMult(u2, rp)) + if q == nil || !IsOnCurve(q) { + return nil, ErrNoRecover + } + return q, nil +} + +// sqrtMod returns a square root of a modulo P (P = 3 mod 4), or nil. +func sqrtMod(a *big.Int) *big.Int { + exp := new(big.Int).Add(P, big.NewInt(1)) + exp.Rsh(exp, 2) + r := new(big.Int).Exp(a, exp, P) + if mod(new(big.Int).Mul(r, r), P).Cmp(mod(a, P)) != 0 { + return nil + } + return r +} diff --git a/secp256k1/secp256k1_test.go b/secp256k1/secp256k1_test.go new file mode 100644 index 0000000..fa5e885 --- /dev/null +++ b/secp256k1/secp256k1_test.go @@ -0,0 +1,130 @@ +package secp256k1 + +import ( + "encoding/hex" + "math/big" + "testing" +) + +func mustDecodeHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("decode hex: %v", err) + } + return b +} + +// TestBasePointOnCurve confirms the generator satisfies y^2 = x^3 + 7. +func TestBasePointOnCurve(t *testing.T) { + if !IsOnCurve(&Point{X: Gx, Y: Gy}) { + t.Fatalf("generator is not on the curve") + } +} + +// TestPointDoublingMatchesScalarMult verifies group consistency: 2*G computed +// via point doubling equals k*G for k=2. +func TestPointDoublingMatchesScalarMult(t *testing.T) { + g := &Point{X: Gx, Y: Gy} + viaDouble := Add(g, g) + viaScalar := ScalarBaseMult(big.NewInt(2)) + if viaDouble.X.Cmp(viaScalar.X) != 0 || viaDouble.Y.Cmp(viaScalar.Y) != 0 { + t.Fatalf("2G mismatch: double=%v scalar=%v", viaDouble, viaScalar) + } + if !IsOnCurve(viaScalar) { + t.Fatalf("2G not on curve") + } +} + +// TestScalarMultDistributes checks (a+b)G == aG + bG. +func TestScalarMultDistributes(t *testing.T) { + a := big.NewInt(12345) + b := big.NewInt(67890) + sum := new(big.Int).Add(a, b) + left := ScalarBaseMult(sum) + right := Add(ScalarBaseMult(a), ScalarBaseMult(b)) + if left.X.Cmp(right.X) != 0 || left.Y.Cmp(right.Y) != 0 { + t.Fatalf("(a+b)G != aG + bG") + } +} + +// TestPublicKeyKnownValues locks the uncompressed public key for the t3 key. +func TestPublicKeyKnownValues(t *testing.T) { + priv := mustDecodeHex(t, "a96135266ba58bb5b3a450b8fe7a9d59ea3e6cb0cdb2300e8138d2d2d48e61a8") + point, err := PublicKey(priv) + if err != nil { + t.Fatalf("PublicKey: %v", err) + } + raw := make([]byte, 64) + point.X.FillBytes(raw[0:32]) + point.Y.FillBytes(raw[32:64]) + want := "076e3db946fcb53d575c4e742a9f8ff8c4b57ded53776b2a80d13022e1647adaa1641f3e2210f5f6a3bdd73d073ac029ba79d576093d8a92dd148e448a8e63cb" + if got := hex.EncodeToString(raw); got != want { + t.Fatalf("pubkey = %s, want %s", got, want) + } + if !IsOnCurve(point) { + t.Fatalf("derived public key not on curve") + } +} + +// TestSignRecoverRoundTrip signs and recovers for several keys and hashes. +func TestSignRecoverRoundTrip(t *testing.T) { + privs := []string{ + "a96135266ba58bb5b3a450b8fe7a9d59ea3e6cb0cdb2300e8138d2d2d48e61a8", + "3252a4cb9b3865c94dbb7d6f50107ca704c1c2b9e3d1eb81753e221c7db8fa25", + "993638df18a38ef2be9f8c915eb9063647d015edc8fb70e3bcb32469bee39c21", + } + hashes := []string{ + "e93945efde601eed484553338e58218f4e81c51261dfda72db2d874f75378fe8", + "0000000000000000000000000000000000000000000000000000000000000001", + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + } + for _, p := range privs { + priv := mustDecodeHex(t, p) + wantPoint, _ := PublicKey(priv) + for _, h := range hashes { + hash := mustDecodeHex(t, h) + sig, err := Sign(priv, hash) + if err != nil { + t.Fatalf("Sign: %v", err) + } + // low-s enforcement + if sig.S.Cmp(halfN) > 0 { + t.Fatalf("signature s is not low-s") + } + recovered, err := RecoverPublicKey(hash, sig) + if err != nil { + t.Fatalf("RecoverPublicKey: %v", err) + } + if recovered.X.Cmp(wantPoint.X) != 0 || recovered.Y.Cmp(wantPoint.Y) != 0 { + t.Fatalf("recovered key differs for priv=%s hash=%s", p, h) + } + } + } +} + +// TestSignIsDeterministic confirms RFC6979-style reproducibility. +func TestSignIsDeterministic(t *testing.T) { + priv := mustDecodeHex(t, "a96135266ba58bb5b3a450b8fe7a9d59ea3e6cb0cdb2300e8138d2d2d48e61a8") + hash := mustDecodeHex(t, "e93945efde601eed484553338e58218f4e81c51261dfda72db2d874f75378fe8") + a, err := Sign(priv, hash) + if err != nil { + t.Fatalf("Sign: %v", err) + } + b, err := Sign(priv, hash) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if hex.EncodeToString(a.Bytes()) != hex.EncodeToString(b.Bytes()) { + t.Fatalf("signatures differ for identical inputs") + } +} + +func TestPublicKeyRejectsInvalid(t *testing.T) { + if _, err := PublicKey(make([]byte, 31)); err == nil { + t.Fatalf("expected error for short key") + } + if _, err := PublicKey(make([]byte, 32)); err == nil { + t.Fatalf("expected error for zero key") + } +} diff --git a/tron/address.go b/tron/address.go new file mode 100644 index 0000000..8d4daca --- /dev/null +++ b/tron/address.go @@ -0,0 +1,121 @@ +package tron + +import ( + "encoding/hex" + "strings" + + "code.luxferre.top/luxferre/kisstron/base58" +) + +// ToBase58CheckAddress converts an address given in any accepted format +// (base58check, 41-prefixed hex, or 0x-prefixed hex) to its base58check +// representation, verifying the checksum. The argument may be a string or a +// byte slice. +func ToBase58CheckAddress(raw any) (string, error) { + switch v := raw.(type) { + case string: + return strToBase58Check(v) + case []byte: + return bytesToBase58Check(v) + } + return "", ErrBadAddress +} + +func strToBase58Check(s string) (string, error) { + if len(s) == 0 { + return "", ErrBadAddress + } + if s[0] == 'T' && len(s) == 34 { + if !base58.IsValid(s) { + return "", ErrBadAddress + } + return s, nil + } + if len(s) == 42 { + if strings.HasPrefix(s, "0x") { + b, err := hex.DecodeString(s[2:]) + if err != nil || len(b) != 20 { + return "", ErrBadAddress + } + return base58.CheckEncode(append([]byte{0x41}, b...)), nil + } + b, err := hex.DecodeString(s) + if err != nil || len(b) != 21 { + return "", ErrBadAddress + } + return base58.CheckEncode(b), nil + } + if strings.HasPrefix(s, "0x") && len(s) == 44 { + b, err := hex.DecodeString(s[2:]) + if err != nil || len(b) != 21 { + return "", ErrBadAddress + } + return base58.CheckEncode(b), nil + } + return "", ErrBadAddress +} + +func bytesToBase58Check(b []byte) (string, error) { + switch { + case len(b) == 21 && b[0] == 0x41: + return base58.CheckEncode(b), nil + case len(b) == 20: + return base58.CheckEncode(append([]byte{0x41}, b...)), nil + default: + return strToBase58Check(string(b)) + } +} + +// ToHexAddress converts an address to its 41-prefixed hex representation. +func ToHexAddress(raw any) (string, error) { + addr, err := ToBase58CheckAddress(raw) + if err != nil { + return "", err + } + b, err := base58.CheckDecode(addr) + if err != nil { + return "", ErrBadAddress + } + return hex.EncodeToString(b), nil +} + +// ToRawAddress returns the 21-byte raw form of an address. +func ToRawAddress(raw any) ([]byte, error) { + addr, err := ToBase58CheckAddress(raw) + if err != nil { + return nil, err + } + return base58.CheckDecode(addr) +} + +// ToTvmAddress returns the 20-byte TVM (Ethereum-style) form of an address. +func ToTvmAddress(raw any) ([]byte, error) { + b, err := ToRawAddress(raw) + if err != nil { + return nil, err + } + return b[1:], nil +} + +// IsBase58CheckAddress reports whether value is a valid base58check address. +func IsBase58CheckAddress(value string) bool { + if len(value) == 0 || value[0] != 'T' { + return false + } + b, err := base58.CheckDecode(value) + return err == nil && len(b) == 21 +} + +// IsHexAddress reports whether value is a valid 41-prefixed hex address. +func IsHexAddress(value string) bool { + if !strings.HasPrefix(value, "41") { + return false + } + b, err := hex.DecodeString(value) + return err == nil && len(b) == 21 +} + +// IsAddress reports whether value is any accepted TRON address format. +func IsAddress(value string) bool { + return IsBase58CheckAddress(value) || IsHexAddress(value) +} diff --git a/tron/client.go b/tron/client.go new file mode 100644 index 0000000..09d0bb0 --- /dev/null +++ b/tron/client.go @@ -0,0 +1,151 @@ +package tron + +import ( + "strings" + "time" +) + +// Client is the subset of tronpy's Tron client used by kisstron. +type Client struct { + provider *HTTPProvider + network string + feeLimit int64 +} + +// NewClient builds a client for the given network and node endpoint. A zero or +// empty endpoint selects the network default. +func NewClient(network, endpoint string, feeLimit int64, apiKey string) *Client { + return &Client{ + provider: NewHTTPProvider(endpoint, 20*time.Second, apiKey), + network: network, + feeLimit: feeLimit, + } +} + +// Provider exposes the underlying HTTP provider (used for diagnostics). +func (c *Client) Provider() *HTTPProvider { return c.provider } + +func (c *Client) GetAccount(addr string) (map[string]any, error) { + b58, err := ToBase58CheckAddress(addr) + if err != nil { + return nil, err + } + ret, err := c.provider.makeRequest("wallet/getaccount", map[string]any{"address": b58, "visible": true}) + if err != nil { + return nil, err + } + if len(ret) == 0 { + return nil, ErrAddressNotFound + } + return ret, nil +} + +func (c *Client) GetAccountResource(addr string) (map[string]any, error) { + b58, err := ToBase58CheckAddress(addr) + if err != nil { + return nil, err + } + ret, err := c.provider.makeRequest("wallet/getaccountresource", map[string]any{"address": b58, "visible": true}) + if err != nil { + return nil, err + } + if len(ret) == 0 { + return nil, ErrAddressNotFound + } + return ret, nil +} + +func (c *Client) getLatestSolidBlockID() (string, error) { + info, err := c.provider.makeRequest("wallet/getnodeinfo", nil) + if err != nil { + return "", err + } + block, _ := info["solidityBlock"].(string) + if block == "" { + return "", ErrAPI + } + return block[strings.LastIndex(block, ",ID:")+4:], nil +} + +func (c *Client) broadcast(tx *Transaction) (map[string]any, error) { + payload, err := c.provider.makeRequest("wallet/broadcasttransaction", tx) + if err != nil { + return nil, err + } + if err := handleAPIError(payload); err != nil { + return nil, err + } + if _, ok := payload["txid"]; !ok { + payload["txid"] = tx.TxID + } + return payload, nil +} + +func (c *Client) getTransactionInfo(txid string) (map[string]any, error) { + if len(txid) != 64 { + return nil, ErrBadHash + } + ret, err := c.provider.makeRequest("wallet/gettransactioninfobyid", map[string]any{"value": txid, "visible": true}) + if err != nil { + return nil, err + } + if err := handleAPIError(ret); err != nil { + return nil, err + } + if len(ret) == 0 { + return nil, ErrTransactionNotFound + } + return ret, nil +} + +func (c *Client) GetContract(addr string) (*Contract, error) { + b58, err := ToBase58CheckAddress(addr) + if err != nil { + return nil, err + } + info, err := c.provider.makeRequest("wallet/getcontract", map[string]any{"value": b58, "visible": true}) + if err != nil { + return nil, err + } + if err := handleAPIError(info); err != nil { + return nil, wrapErr(ErrContractNotFound, err.Error()) + } + return &Contract{Address: b58, client: c}, nil +} + +// triggerConstantContract performs a read-only contract call and returns the +// first constant result (hex, without 0x). +func (c *Client) triggerConstantContract(owner, contractAddr, functionSelector, parameter string) (string, error) { + ownerB58, err := ToBase58CheckAddress(owner) + if err != nil { + return "", err + } + contractB58, err := ToBase58CheckAddress(contractAddr) + if err != nil { + return "", err + } + ret, err := c.provider.makeRequest("wallet/triggerconstantcontract", map[string]any{ + "owner_address": ownerB58, + "contract_address": contractB58, + "function_selector": functionSelector, + "parameter": parameter, + "visible": true, + }) + if err != nil { + return "", err + } + if err := handleAPIError(ret); err != nil { + return "", err + } + if res, ok := ret["result"].(map[string]any); ok { + if _, bad := res["message"]; bad { + return "", ErrTvm + } + } + results, ok := ret["constant_result"].([]any) + if !ok || len(results) == 0 { + return "", ErrTvm + } + first, _ := results[0].(string) + return first, nil +} diff --git a/tron/contract.go b/tron/contract.go new file mode 100644 index 0000000..adfe6a7 --- /dev/null +++ b/tron/contract.go @@ -0,0 +1,109 @@ +package tron + +import ( + "encoding/hex" + "math/big" + + "code.luxferre.top/luxferre/kisstron/keccak" +) + +// zeroOwner is the owner address used for read-only TRC20 calls, matching +// tronpy's Contract default (0x41 followed by 20 zero bytes). +const zeroOwner = "410000000000000000000000000000000000000000" + +// selector returns the 4-byte ABI function selector (as hex) for a signature. +func selector(signature string) string { + h := keccak.Sum256([]byte(signature)) + return hex.EncodeToString(h[:4]) +} + +var ( + selectorDecimals = selector("decimals()") + selectorBalanceOf = selector("balanceOf(address)") + selectorTransfer = selector("transfer(address,uint256)") +) + +// Contract is a minimal handle to a TRC20 contract. Only the three TRC20 calls +// used by kisstron (decimals, balanceOf, transfer) are supported, so no ABI is +// fetched or parsed. +type Contract struct { + Address string + client *Client +} + +// Decimals returns the token's decimal precision. +func (c *Contract) Decimals() (int, error) { + res, err := c.client.triggerConstantContract(zeroOwner, c.Address, "decimals()", "") + if err != nil { + return 0, err + } + v, err := decodeUint256(res) + if err != nil { + return 0, err + } + return int(v.Int64()), nil +} + +// BalanceOf returns the raw token balance of addr. +func (c *Contract) BalanceOf(addr string) (*big.Int, error) { + param, err := encodeAddressParam(addr) + if err != nil { + return nil, err + } + res, err := c.client.triggerConstantContract(zeroOwner, c.Address, "balanceOf(address)", param) + if err != nil { + return nil, err + } + return decodeUint256(res) +} + +// Transfer builds a signed-ready TRC20 transfer transaction builder. +func (c *Contract) Transfer(from, to string, amount *big.Int) (*TransactionBuilder, error) { + fromAddr, err := encodeAddressParam(to) + if err != nil { + return nil, err + } + fromHex, err := ToHexAddress(from) + if err != nil { + return nil, err + } + contractHex, err := ToHexAddress(c.Address) + if err != nil { + return nil, err + } + data := selectorTransfer + fromAddr + encodeUint256(amount) + return c.client.newTransactionBuilder("TriggerSmartContract", TriggerValue{ + OwnerAddress: fromHex, + ContractAddress: contractHex, + Data: data, + }), nil +} + +// encodeAddressParam left-pads a TVM address to a 32-byte ABI word. +func encodeAddressParam(addr string) (string, error) { + tvm, err := ToTvmAddress(addr) + if err != nil { + return "", err + } + word := make([]byte, 32) + copy(word[32-len(tvm):], tvm) + return hex.EncodeToString(word), nil +} + +// encodeUint256 encodes a non-negative integer as a 32-byte ABI word. +func encodeUint256(v *big.Int) string { + word := make([]byte, 32) + if v != nil { + v.FillBytes(word) + } + return hex.EncodeToString(word) +} + +// decodeUint256 decodes a 32-byte ABI word (hex) into a big integer. +func decodeUint256(hexStr string) (*big.Int, error) { + b, err := hex.DecodeString(hexStr) + if err != nil { + return nil, ErrTvm + } + return new(big.Int).SetBytes(b), nil +} diff --git a/tron/errors.go b/tron/errors.go new file mode 100644 index 0000000..9d2da7f --- /dev/null +++ b/tron/errors.go @@ -0,0 +1,43 @@ +// Package tron implements the subset of TRON blockchain interaction required by +// the kisstron CLI wallet: key/address handling, HTTP node access, transaction +// building/signing/broadcasting and minimal TRC20 calls. +package tron + +import "errors" + +// Sentinel errors mirror tronpy.exceptions so API failures can be mapped the +// same way the original Python implementation does. +var ( + ErrBadAddress = errors.New("invalid address") + ErrAddressNotFound = errors.New("account not found on-chain") + ErrBadKey = errors.New("invalid private key") + ErrBadSignature = errors.New("bad signature") + ErrBadHash = errors.New("wrong transaction hash length") + ErrTapos = errors.New("tapos error") + ErrTransaction = errors.New("transaction error") + ErrTvm = errors.New("tvm error") + ErrValidation = errors.New("validation error") + ErrAPI = errors.New("api error") + ErrNotFound = errors.New("not found") + ErrTransactionNotFound = errors.New("transaction not found") + ErrContractNotFound = errors.New("contract address not found") +) + +// apiError carries a node message while unwrapping to a sentinel error, so its +// Error() string matches Python's `str(e)` (the bare message) and errors.Is +// still identifies the failure class. +type apiError struct { + sentinel error + msg string +} + +func (e *apiError) Error() string { return e.msg } +func (e *apiError) Unwrap() error { return e.sentinel } + +// wrapErr attaches a message to a sentinel error. +func wrapErr(sentinel error, msg string) error { + if msg == "" { + return sentinel + } + return &apiError{sentinel: sentinel, msg: msg} +} diff --git a/tron/keys.go b/tron/keys.go new file mode 100644 index 0000000..b57ffee --- /dev/null +++ b/tron/keys.go @@ -0,0 +1,97 @@ +package tron + +import ( + "encoding/hex" + + "code.luxferre.top/luxferre/kisstron/base58" + "code.luxferre.top/luxferre/kisstron/keccak" + "code.luxferre.top/luxferre/kisstron/secp256k1" +) + +// PrivateKey is a validated 32-byte secp256k1 private key. +type PrivateKey struct { + raw []byte + Pub *PublicKey +} + +// PublicKey is an uncompressed secp256k1 public key (64 bytes, x || y). +type PublicKey struct { + raw []byte +} + +// NewPrivateKey validates and wraps a 32-byte private key. +func NewPrivateKey(b []byte) (*PrivateKey, error) { + if _, err := secp256k1.PublicKey(b); err != nil { + return nil, ErrBadKey + } + return newPrivateKeyUnchecked(b), nil +} + +func newPrivateKeyUnchecked(b []byte) *PrivateKey { + p, _ := secp256k1.PublicKey(b) + raw := make([]byte, 64) + p.X.FillBytes(raw[0:32]) + p.Y.FillBytes(raw[32:64]) + return &PrivateKey{raw: append([]byte{}, b...), Pub: &PublicKey{raw: raw}} +} + +// FromHex parses a hex-encoded private key. +func PrivateKeyFromHex(s string) (*PrivateKey, error) { + b, err := hex.DecodeString(s) + if err != nil { + return nil, ErrBadKey + } + return NewPrivateKey(b) +} + +// Bytes returns the 32-byte private key. +func (k *PrivateKey) Bytes() []byte { + return append([]byte{}, k.raw...) +} + +// Hex returns the private key as a hex string. +func (k *PrivateKey) Hex() string { + return hex.EncodeToString(k.raw) +} + +// Bytes returns the uncompressed public key (64 bytes). +func (p *PublicKey) Bytes() []byte { + return append([]byte{}, p.raw...) +} + +// Hex returns the uncompressed public key as a hex string. +func (p *PublicKey) Hex() string { + return hex.EncodeToString(p.raw) +} + +// ToBase58CheckAddress returns the TRON base58check address of the key. +func (p *PublicKey) ToBase58CheckAddress() string { + h := keccak.Sum256(p.raw) + payload := append([]byte{0x41}, h[12:]...) + return base58.CheckEncode(payload) +} + +// ToHexAddress returns the 41-prefixed hex address of the key. +func (p *PublicKey) ToHexAddress() string { + h := keccak.Sum256(p.raw) + return "41" + hex.EncodeToString(h[12:]) +} + +// Sign signs a 32-byte message hash (e.g. a transaction id) with the key. +func (k *PrivateKey) Sign(hash []byte) ([]byte, error) { + sig, err := secp256k1.Sign(k.raw, hash) + if err != nil { + return nil, err + } + return sig.Bytes(), nil +} + +// GenerateAddressInfo collects all display fields for a private key. +func GenerateAddressInfo(k *PrivateKey) map[string]string { + return map[string]string{ + "base58check_address": k.Pub.ToBase58CheckAddress(), + "hex_address": k.Pub.ToHexAddress(), + "private_key": k.Hex(), + "public_key": k.Pub.Hex(), + } +} diff --git a/tron/keys_test.go b/tron/keys_test.go new file mode 100644 index 0000000..221ee69 --- /dev/null +++ b/tron/keys_test.go @@ -0,0 +1,117 @@ +package tron + +import ( + "encoding/hex" + "testing" +) + +// Golden vectors copied verbatim from the repository fixtures (t2, t3). +func TestMnemonicDerivationGoldenVectors(t *testing.T) { + cases := []struct { + name string + mnemonic string + priv string + pub string + addr string + hexAddr string + }{ + { + name: "t3", + mnemonic: "cloth print knife public box polar impose kingdom phone favorite rose lounge", + priv: "a96135266ba58bb5b3a450b8fe7a9d59ea3e6cb0cdb2300e8138d2d2d48e61a8", + pub: "076e3db946fcb53d575c4e742a9f8ff8c4b57ded53776b2a80d13022e1647adaa1641f3e2210f5f6a3bdd73d073ac029ba79d576093d8a92dd148e448a8e63cb", + addr: "TG9vH8c2jBWq2wSnksrq5mVyq2wjhG4A1n", + hexAddr: "4143d75ee7cdcd7f2fcd4fc3b4eb574b6bdc2a3fcb", + }, + { + name: "t2", + mnemonic: "bullet tissue frozen garment immune valley anxiety nurse vicious caution divert latin", + priv: "3252a4cb9b3865c94dbb7d6f50107ca704c1c2b9e3d1eb81753e221c7db8fa25", + pub: "ede91cec53b797aa5951b707703a2b20150b97f642abc214289c935ee97ce9959d52acc45ccc5b7907f9a9bdb7f2bc2071789c91ea477e4a2485c92c8e18fb17", + addr: "TUSQ1u3HKUsTpfsi5uRXScUzES9RN1w8kk", + hexAddr: "41ca96b08266d7b36857d8cf2712d449ea07bf0275", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + key, err := DeriveFromMnemonic(tc.mnemonic, "", "") + if err != nil { + t.Fatalf("DeriveFromMnemonic: %v", err) + } + if key.Hex() != tc.priv { + t.Fatalf("private key = %s, want %s", key.Hex(), tc.priv) + } + if key.Pub.Hex() != tc.pub { + t.Fatalf("public key = %s, want %s", key.Pub.Hex(), tc.pub) + } + if got := key.Pub.ToBase58CheckAddress(); got != tc.addr { + t.Fatalf("address = %s, want %s", got, tc.addr) + } + if got := key.Pub.ToHexAddress(); got != tc.hexAddr { + t.Fatalf("hex address = %s, want %s", got, tc.hexAddr) + } + }) + } +} + +// TestAddressFromPrivateKey checks the pk3 fixture (t3 private key). +func TestAddressFromPrivateKey(t *testing.T) { + pk := "a96135266ba58bb5b3a450b8fe7a9d59ea3e6cb0cdb2300e8138d2d2d48e61a8" + key, err := PrivateKeyFromHex(pk) + if err != nil { + t.Fatalf("PrivateKeyFromHex: %v", err) + } + if got := key.Pub.ToBase58CheckAddress(); got != "TG9vH8c2jBWq2wSnksrq5mVyq2wjhG4A1n" { + t.Fatalf("address = %s", got) + } +} + +func TestAddressConversions(t *testing.T) { + b58 := "TG9vH8c2jBWq2wSnksrq5mVyq2wjhG4A1n" + hexAddr := "4143d75ee7cdcd7f2fcd4fc3b4eb574b6bdc2a3fcb" + + got, err := ToBase58CheckAddress(hexAddr) + if err != nil || got != b58 { + t.Fatalf("ToBase58CheckAddress(%s) = %s, %v", hexAddr, got, err) + } + gotHex, err := ToHexAddress(b58) + if err != nil || gotHex != hexAddr { + t.Fatalf("ToHexAddress(%s) = %s, %v", b58, gotHex, err) + } + raw, err := ToRawAddress(b58) + if err != nil || len(raw) != 21 || raw[0] != 0x41 { + t.Fatalf("ToRawAddress(%s) = %x, %v", b58, raw, err) + } + if !IsAddress(b58) || !IsAddress(hexAddr) { + t.Fatalf("IsAddress should accept both formats") + } + if IsAddress("TNotARealAddress000000000000000000") { + t.Fatalf("IsAddress accepted an invalid address") + } +} + +func TestSignRecoverRoundTrip(t *testing.T) { + pk, _ := hex.DecodeString("a96135266ba58bb5b3a450b8fe7a9d59ea3e6cb0cdb2300e8138d2d2d48e61a8") + key, err := NewPrivateKey(pk) + if err != nil { + t.Fatalf("NewPrivateKey: %v", err) + } + hash := make([]byte, 32) + for i := range hash { + hash[i] = byte(i) + } + sigBytes, err := key.Sign(hash) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if len(sigBytes) != 65 { + t.Fatalf("signature length = %d, want 65", len(sigBytes)) + } + rec, err := RecoverAddress(hash, sigBytes) + if err != nil { + t.Fatalf("RecoverAddress: %v", err) + } + if rec != key.Pub.ToBase58CheckAddress() { + t.Fatalf("recovered %s, want %s", rec, key.Pub.ToBase58CheckAddress()) + } +} diff --git a/tron/proto.go b/tron/proto.go new file mode 100644 index 0000000..bc172b7 --- /dev/null +++ b/tron/proto.go @@ -0,0 +1,244 @@ +package tron + +import ( + "crypto/sha256" + "encoding/hex" +) + +// This file implements the minimal protobuf encoding required to compute TRON +// transaction ids (sha256 of the serialized Transaction.raw message) and to +// mirror tronpy's JSON transaction representation. Only the fields the wallet +// populates are handled; proto3 default (zero) values are omitted. + +// --- protobuf wire primitives --- + +const ( + wireVarint = 0 + wireBytes = 2 +) + +type pbWriter struct { + buf []byte +} + +func (w *pbWriter) rawVarint(v uint64) { + for v >= 0x80 { + w.buf = append(w.buf, byte(v)|0x80) + v >>= 7 + } + w.buf = append(w.buf, byte(v)) +} + +func (w *pbWriter) tag(field, wire int) { + w.rawVarint(uint64(field)<<3 | uint64(wire)) +} + +func (w *pbWriter) varintField(field int, v uint64) { + if v == 0 { + return + } + w.tag(field, wireVarint) + w.rawVarint(v) +} + +func (w *pbWriter) int64Field(field int, v int64) { + w.varintField(field, uint64(v)) +} + +func (w *pbWriter) bytesField(field int, b []byte) { + if len(b) == 0 { + return + } + w.tag(field, wireBytes) + w.rawVarint(uint64(len(b))) + w.buf = append(w.buf, b...) +} + +func (w *pbWriter) stringField(field int, s string) { + w.bytesField(field, []byte(s)) +} + +func (w *pbWriter) messageField(field int, fn func(*pbWriter)) { + sub := &pbWriter{} + fn(sub) + w.bytesField(field, sub.buf) +} + +// --- contract value types (JSON shape matches tronpy dicts) --- + +// TransferValue is the value payload of a TransferContract. +type TransferValue struct { + OwnerAddress string `json:"owner_address"` + ToAddress string `json:"to_address"` + Amount int64 `json:"amount"` +} + +// TriggerValue is the value payload of a TriggerSmartContract. +type TriggerValue struct { + OwnerAddress string `json:"owner_address"` + ContractAddress string `json:"contract_address"` + Data string `json:"data"` + CallTokenValue int64 `json:"call_token_value"` + CallValue int64 `json:"call_value"` + TokenID int64 `json:"token_id"` +} + +// FreezeValue is the value payload of a FreezeBalanceV2Contract. +type FreezeValue struct { + OwnerAddress string `json:"owner_address"` + FrozenBalance int64 `json:"frozen_balance"` + Resource string `json:"resource"` +} + +// UnfreezeValue is the value payload of an UnfreezeBalanceV2Contract. +type UnfreezeValue struct { + OwnerAddress string `json:"owner_address"` + UnfreezeBalance int64 `json:"unfreeze_balance"` + Resource string `json:"resource"` +} + +// ParameterJSON mirrors tronpy's {value, type_url} contract parameter. +type ParameterJSON struct { + Value any `json:"value"` + TypeURL string `json:"type_url"` +} + +// ContractJSON mirrors tronpy's contract entry inside raw_data. +type ContractJSON struct { + Parameter ParameterJSON `json:"parameter"` + Type string `json:"type"` +} + +// RawData mirrors tronpy's raw_data dict; addresses are hex strings. +type RawData struct { + Contract []ContractJSON `json:"contract"` + Timestamp int64 `json:"timestamp"` + Expiration int64 `json:"expiration"` + RefBlockBytes string `json:"ref_block_bytes"` + RefBlockHash string `json:"ref_block_hash"` + FeeLimit int64 `json:"fee_limit,omitempty"` +} + +// --- encoding --- + +func contractTypeCode(t string) uint64 { + switch t { + case "TransferContract": + return 1 + case "TriggerSmartContract": + return 31 + case "FreezeBalanceV2Contract": + return 54 + case "UnfreezeBalanceV2Contract": + return 55 + } + return 0 +} + +func resourceCode(r string) uint64 { + if r == "ENERGY" { + return 1 + } + return 0 +} + +func (c *ContractJSON) serializeValue() ([]byte, error) { + w := &pbWriter{} + switch v := c.Parameter.Value.(type) { + case TransferValue: + owner, err := ToRawAddress(v.OwnerAddress) + if err != nil { + return nil, err + } + to, err := ToRawAddress(v.ToAddress) + if err != nil { + return nil, err + } + w.bytesField(1, owner) + w.bytesField(2, to) + w.int64Field(3, v.Amount) + case TriggerValue: + owner, err := ToRawAddress(v.OwnerAddress) + if err != nil { + return nil, err + } + contractAddr, err := ToRawAddress(v.ContractAddress) + if err != nil { + return nil, err + } + data, err := hex.DecodeString(v.Data) + if err != nil { + return nil, err + } + w.bytesField(1, owner) + w.bytesField(2, contractAddr) + w.int64Field(3, v.CallValue) + w.bytesField(4, data) + w.int64Field(5, v.CallTokenValue) + w.int64Field(6, v.TokenID) + case FreezeValue: + owner, err := ToRawAddress(v.OwnerAddress) + if err != nil { + return nil, err + } + w.bytesField(1, owner) + w.int64Field(2, v.FrozenBalance) + w.varintField(3, resourceCode(v.Resource)) + case UnfreezeValue: + owner, err := ToRawAddress(v.OwnerAddress) + if err != nil { + return nil, err + } + w.bytesField(1, owner) + w.int64Field(2, v.UnfreezeBalance) + w.varintField(3, resourceCode(v.Resource)) + default: + return nil, ErrValidation + } + return w.buf, nil +} + +// serialize encodes the raw data into the protobuf Transaction.raw message. +func (rd *RawData) serialize() ([]byte, error) { + w := &pbWriter{} + rbb, err := hex.DecodeString(rd.RefBlockBytes) + if err != nil { + return nil, err + } + w.bytesField(1, rbb) + rbh, err := hex.DecodeString(rd.RefBlockHash) + if err != nil { + return nil, err + } + w.bytesField(4, rbh) + w.int64Field(8, rd.Expiration) + for i := range rd.Contract { + c := &rd.Contract[i] + val, err := c.serializeValue() + if err != nil { + return nil, err + } + typeURL := c.Parameter.TypeURL + code := contractTypeCode(c.Type) + w.messageField(11, func(sub *pbWriter) { + sub.varintField(1, code) + sub.messageField(2, func(anyW *pbWriter) { + anyW.stringField(1, typeURL) + anyW.bytesField(2, val) + }) + }) + } + w.int64Field(14, rd.Timestamp) + w.int64Field(18, rd.FeeLimit) + return w.buf, nil +} + +// TxID computes the transaction id (hex) from the raw data. +func (rd *RawData) TxID() (string, error) { + b, err := rd.serialize() + if err != nil { + return "", err + } + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]), nil +} diff --git a/tron/proto_test.go b/tron/proto_test.go new file mode 100644 index 0000000..5a9af5e --- /dev/null +++ b/tron/proto_test.go @@ -0,0 +1,67 @@ +package tron + +import "testing" + +// TestTxIDMatchesPython locks the hand-rolled protobuf encoder to the reference +// values produced by tronpy's proto.calculate_txid_from_raw_data. These vectors +// were computed with the vendored tronpy 0.6.2 + protobuf runtime. +func TestTxIDMatchesPython(t *testing.T) { + const baseTS = int64(1600000000000) + + transfer := &RawData{ + Contract: []ContractJSON{{ + Parameter: ParameterJSON{ + Value: TransferValue{ + OwnerAddress: "41ca96b08266d7b36857d8cf2712d449ea07bf0275", + ToAddress: "4143d75ee7cdcd7f2fcd4fc3b4eb574b6bdc2a3fcb", + Amount: 1000000, + }, + TypeURL: "type.googleapis.com/protocol.TransferContract", + }, + Type: "TransferContract", + }}, + Timestamp: baseTS, + Expiration: baseTS + 60000, + RefBlockBytes: "1234", + RefBlockHash: "1234567890abcdef", + } + + trigger := &RawData{ + Contract: []ContractJSON{{ + Parameter: ParameterJSON{ + Value: TriggerValue{ + OwnerAddress: "4143d75ee7cdcd7f2fcd4fc3b4eb574b6bdc2a3fcb", + ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + Data: "a9059cbb000000000000000000000000ca96b08266d7b36857d8cf2712d449ea07bf020000000000000000000000000000000000000000000000000000000000000001", + }, + TypeURL: "type.googleapis.com/protocol.TriggerSmartContract", + }, + Type: "TriggerSmartContract", + }}, + Timestamp: baseTS, + Expiration: baseTS + 60000, + RefBlockBytes: "1234", + RefBlockHash: "1234567890abcdef", + FeeLimit: 100000000, + } + + cases := []struct { + name string + rd *RawData + want string + }{ + {"transfer", transfer, "e93945efde601eed484553338e58218f4e81c51261dfda72db2d874f75378fe8"}, + {"trigger", trigger, "0c1dec1fa73590ddf4a3975b78791e72d18998ae42751af199384587c2ef12d0"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.rd.TxID() + if err != nil { + t.Fatalf("TxID: %v", err) + } + if got != tc.want { + t.Fatalf("txid = %s, want %s", got, tc.want) + } + }) + } +} diff --git a/tron/provider.go b/tron/provider.go new file mode 100644 index 0000000..f002fd0 --- /dev/null +++ b/tron/provider.go @@ -0,0 +1,158 @@ +package tron + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "errors" + "io" + "math/rand" + "net/http" + "os" + "strings" + "time" +) + +// defaultAPIKeys are the public TronGrid API keys used by tronpy when the +// endpoint is a TronGrid host and no explicit key is supplied. +var defaultAPIKeys = []string{ + "f92221d5-7056-4366-b96f-65d3662ec2d9", + "1e0a625f-cfa5-43ee-ba41-a09db1aae55f", + "f399168e-2259-481c-90fc-6b3d984c5463", + "da63253b-aa9c-46e7-a4e8-22d259a8026d", + "88c10958-af7b-4d5a-8eef-6e84bf5fb809", + "169bb4b3-cbe8-449a-984e-80e9adacac55", +} + +// HTTPProvider is a minimal JSON-over-HTTP client for a TRON fullnode. It +// mirrors tronpy.providers.HTTPProvider: POST {} with a JSON body and +// an optional Tron-Pro-Api-Key header for TronGrid endpoints. +type HTTPProvider struct { + endpointURI string + timeout time.Duration + useAPIKey bool + apiKeys []string + client *http.Client +} + +// NewHTTPProvider creates a provider for the given endpoint URI. When the +// endpoint is a TronGrid host an api key is used (the supplied one, or the +// public defaults). An empty endpoint falls back to the TRONPY_HTTP_PROVIDER_URI +// environment variable and then to api.trongrid.io. +func NewHTTPProvider(endpointURI string, timeout time.Duration, apiKey string) *HTTPProvider { + if endpointURI == "" { + endpointURI = os.Getenv("TRONPY_HTTP_PROVIDER_URI") + } + if endpointURI == "" { + endpointURI = "https://api.trongrid.io/" + } + p := &HTTPProvider{ + endpointURI: strings.TrimRight(endpointURI, "/"), + timeout: timeout, + client: &http.Client{Timeout: timeout}, + } + if strings.Contains(p.endpointURI, "trongrid") { + p.useAPIKey = true + if apiKey != "" { + p.apiKeys = []string{apiKey} + } else { + p.apiKeys = append([]string{}, defaultAPIKeys...) + } + } + return p +} + +// Endpoint returns the configured endpoint URI. +func (p *HTTPProvider) Endpoint() string { return p.endpointURI } + +// makeRequest issues a POST to {endpoint}/{method} with the JSON params. +func (p *HTTPProvider) makeRequest(method string, params any) (map[string]any, error) { + if params == nil { + params = map[string]any{} + } + body, err := json.Marshal(params) + if err != nil { + return nil, err + } + for attempt := 0; ; attempt++ { + req, err := http.NewRequest(http.MethodPost, p.endpointURI+"/"+method, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "kisstron") + if p.useAPIKey && len(p.apiKeys) > 0 { + req.Header.Set("Tron-Pro-Api-Key", p.apiKeys[rand.Intn(len(p.apiKeys))]) + } + resp, err := p.client.Do(req) + if err != nil { + return nil, err + } + data, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return nil, readErr + } + if resp.StatusCode == http.StatusForbidden && strings.Contains(string(data), "Exceed the user daily usage") { + if !p.rotateAPIKey() || attempt >= len(defaultAPIKeys) { + return nil, errors.New("rate limit! please add more API keys") + } + continue + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, errors.New("http error: " + resp.Status) + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var out map[string]any + if err := dec.Decode(&out); err != nil { + return nil, err + } + return out, nil + } +} + +func (p *HTTPProvider) rotateAPIKey() bool { + if len(p.apiKeys) == 0 { + return false + } + p.apiKeys = p.apiKeys[1:] + return len(p.apiKeys) > 0 +} + +// handleAPIError maps a node error payload to the same typed errors as +// tronpy. It returns nil when the payload indicates success. +func handleAPIError(payload map[string]any) error { + if res, ok := payload["result"].(bool); ok && res { + return nil + } + if e, ok := payload["Error"].(string); ok { + return errors.New(e) + } + if code, ok := payload["code"].(string); ok { + msg := code + if m, ok := payload["message"].(string); ok { + if decoded, err := hex.DecodeString(m); err == nil { + msg = string(decoded) + } else { + msg = m + } + } + switch code { + case "SIGERROR": + return wrapErr(ErrBadSignature, msg) + case "TAPOS_ERROR": + return wrapErr(ErrTapos, msg) + case "TRANSACTION_EXPIRATION_ERROR", "TOO_BIG_TRANSACTION_ERROR": + return wrapErr(ErrTransaction, msg) + case "CONTRACT_VALIDATE_ERROR": + return wrapErr(ErrValidation, msg) + default: + return wrapErr(ErrAPI, msg) + } + } + if res, ok := payload["result"].(map[string]any); ok { + return handleAPIError(res) + } + return nil +} diff --git a/tron/signature_test.go b/tron/signature_test.go new file mode 100644 index 0000000..f85e921 --- /dev/null +++ b/tron/signature_test.go @@ -0,0 +1,68 @@ +package tron + +import ( + "encoding/hex" + "testing" +) + +// TestSignatureMatchesPython locks the hand-rolled RFC6979/low-s recoverable +// signing to values produced by coincurve/libsecp256k1 via tronpy's +// PrivateKey.sign_msg_hash. The hashes are the txids computed elsewhere in the +// suite. +func TestSignatureMatchesPython(t *testing.T) { + cases := []struct { + name string + priv string + hash string + want string + addr string + }{ + { + "t3_transfer_txid", + "a96135266ba58bb5b3a450b8fe7a9d59ea3e6cb0cdb2300e8138d2d2d48e61a8", + "e93945efde601eed484553338e58218f4e81c51261dfda72db2d874f75378fe8", + "a48ffafc8bdb57bdb64216b0facdff77cd2bb7090e1e1b7dcd6e829823c7cd207422b7bd910a063b96f562e32dc566b3b6a1c935d7c63c01642dda14b0b4966d00", + "TG9vH8c2jBWq2wSnksrq5mVyq2wjhG4A1n", + }, + { + "t3_sparse_hash", + "a96135266ba58bb5b3a450b8fe7a9d59ea3e6cb0cdb2300e8138d2d2d48e61a8", + "0000000000000000000000000000000000000000000000000000000000000001", + "2df95cd58fc299c155dd51e13799c42c00786b1d18a1423f9b098f8354f4785e670d6a75c0fea96162a990689a9ee0089e08bd9e663dfe27104902a0bdf6a04f01", + "TG9vH8c2jBWq2wSnksrq5mVyq2wjhG4A1n", + }, + { + "t2_trigger_txid", + "3252a4cb9b3865c94dbb7d6f50107ca704c1c2b9e3d1eb81753e221c7db8fa25", + "0c1dec1fa73590ddf4a3975b78791e72d18998ae42751af199384587c2ef12d0", + "3136e1e010697c23c3aa7ba78b3eee06a3fbb75d32e37c0730f2b2a733ed5da6191f93d706b0c6c18d480d0f27cfd208bf64ce157ee83f6ff6082fdd230d0cab01", + "TUSQ1u3HKUsTpfsi5uRXScUzES9RN1w8kk", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + key, err := PrivateKeyFromHex(tc.priv) + if err != nil { + t.Fatalf("PrivateKeyFromHex: %v", err) + } + h, err := hex.DecodeString(tc.hash) + if err != nil { + t.Fatalf("decode hash: %v", err) + } + sig, err := key.Sign(h) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if got := hex.EncodeToString(sig); got != tc.want { + t.Fatalf("signature = %s, want %s", got, tc.want) + } + rec, err := RecoverAddress(h, sig) + if err != nil { + t.Fatalf("RecoverAddress: %v", err) + } + if rec != tc.addr { + t.Fatalf("recovered = %s, want %s", rec, tc.addr) + } + }) + } +} diff --git a/tron/tokens.go b/tron/tokens.go new file mode 100644 index 0000000..542a38b --- /dev/null +++ b/tron/tokens.go @@ -0,0 +1,65 @@ +package tron + +// Token is a hardcoded TRC20 token definition. +type Token struct { + Key string + Contract string + Ticker string + ShowBalance bool +} + +// Hardcoded network and scale constants matching the original kisstron.py. +const ( + // DefaultNodeAddr is the public TRON node used for mainnet without an API key. + DefaultNodeAddr = "http://3.225.171.164:8090" + // FeeLimitMainnet is the default mainnet fee limit in SUN (100 TRX). + FeeLimitMainnet = 100000000 + // FeeLimitTestnet is the default testnet fee limit in SUN (1000 TRX). + FeeLimitTestnet = 1000000000 + // TrxScale is the number of SUN in one TRX. + TrxScale = 1000000.0 +) + +// tokensByNetwork preserves the insertion order of the original dict so wallet +// info output matches byte-for-byte. +var tokensByNetwork = map[string][]Token{ + "mainnet": { + {Key: "usdt", Contract: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Ticker: "USDT", ShowBalance: true}, + {Key: "usdc", Contract: "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8", Ticker: "USDC", ShowBalance: true}, + {Key: "tusd", Contract: "TUpMhErZL2fhh4sVNULAbNKLokS4GjC1F4", Ticker: "TUSD", ShowBalance: true}, + }, + "nile": { + {Key: "usdt", Contract: "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj", Ticker: "USDT", ShowBalance: true}, + {Key: "usdc", Contract: "TEMVynQpntMqkPxP6wXTW2K7e4sM3cRmWz", Ticker: "USDC", ShowBalance: true}, + }, + "shasta": { + {Key: "usdt", Contract: "TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs", Ticker: "USDT", ShowBalance: true}, + {Key: "usdc", Contract: "TSdZwNqpHofzP6BsBKGQUWdBeJphLmF6id", Ticker: "USDC", ShowBalance: true}, + }, +} + +var defaultEndpoints = map[string]string{ + "mainnet": "", + "nile": "https://api.nileex.io", + "shasta": "https://api.shasta.trongrid.io", +} + +// TokensForNetwork returns the tokens for a network in a stable order. +func TokensForNetwork(network string) []Token { + return tokensByNetwork[network] +} + +// TokenByKey looks up a token by its ticker key for a network. +func TokenByKey(network, key string) (Token, bool) { + for _, t := range tokensByNetwork[network] { + if t.Key == key { + return t, true + } + } + return Token{}, false +} + +// DefaultEndpoint returns the default node endpoint for a network. +func DefaultEndpoint(network string) string { + return defaultEndpoints[network] +} diff --git a/tron/tx.go b/tron/tx.go new file mode 100644 index 0000000..f601f2f --- /dev/null +++ b/tron/tx.go @@ -0,0 +1,206 @@ +package tron + +import ( + "encoding/hex" + "encoding/json" + "errors" + "time" +) + +// Transaction is a signed or unsigned TRON transaction. Its field order matches +// tronpy's Transaction.to_json() dict order so the marshalled JSON is +// byte-compatible with the Python offline output. +type Transaction struct { + TxID string `json:"txID"` + RawData *RawData `json:"raw_data"` + Signature []string `json:"signature"` + Permission any `json:"permission"` + client *Client +} + +func currentMillis() int64 { + return time.Now().UnixNano() / int64(time.Millisecond) +} + +// TransactionBuilder assembles a transaction before signing. +type TransactionBuilder struct { + client *Client + rawData *RawData +} + +// newTransactionBuilder creates a builder for a single contract. +func (c *Client) newTransactionBuilder(typ string, value any) *TransactionBuilder { + now := currentMillis() + rd := &RawData{ + Contract: []ContractJSON{{ + Parameter: ParameterJSON{ + Value: value, + TypeURL: "type.googleapis.com/protocol." + typ, + }, + Type: typ, + }}, + Timestamp: now, + Expiration: now + 60000, + } + if typ == "TriggerSmartContract" || typ == "CreateSmartContract" { + rd.FeeLimit = c.feeLimit + } + return &TransactionBuilder{client: c, rawData: rd} +} + +// Transfer builds a TRX transfer builder. Amount is in SUN. +func (c *Client) Transfer(from, to string, amount int64) (*TransactionBuilder, error) { + fromHex, err := ToHexAddress(from) + if err != nil { + return nil, err + } + toHex, err := ToHexAddress(to) + if err != nil { + return nil, err + } + return c.newTransactionBuilder("TransferContract", TransferValue{ + OwnerAddress: fromHex, + ToAddress: toHex, + Amount: amount, + }), nil +} + +// FreezeBalance builds a FreezeBalanceV2 builder for the given resource. +func (c *Client) FreezeBalance(owner string, amount int64, resource string) (*TransactionBuilder, error) { + ownerHex, err := ToHexAddress(owner) + if err != nil { + return nil, err + } + return c.newTransactionBuilder("FreezeBalanceV2Contract", FreezeValue{ + OwnerAddress: ownerHex, + FrozenBalance: amount, + Resource: resource, + }), nil +} + +// UnfreezeBalance builds an UnfreezeBalanceV2 builder for the given resource. +func (c *Client) UnfreezeBalance(owner string, amount int64, resource string) (*TransactionBuilder, error) { + ownerHex, err := ToHexAddress(owner) + if err != nil { + return nil, err + } + return c.newTransactionBuilder("UnfreezeBalanceV2Contract", UnfreezeValue{ + OwnerAddress: ownerHex, + UnfreezeBalance: amount, + Resource: resource, + }), nil +} + +// Build fills the reference block fields from the chain and computes the txid. +func (b *TransactionBuilder) Build() (*Transaction, error) { + refID, err := b.client.getLatestSolidBlockID() + if err != nil { + return nil, err + } + if len(refID) < 32 { + return nil, ErrBadHash + } + b.rawData.RefBlockBytes = refID[12:16] + b.rawData.RefBlockHash = refID[16:32] + txid, err := b.rawData.TxID() + if err != nil { + return nil, err + } + return &Transaction{ + TxID: txid, + RawData: b.rawData, + Signature: []string{}, + client: b.client, + }, nil +} + +// Sign signs the transaction with the given private key. +func (tx *Transaction) Sign(key *PrivateKey) error { + if tx.TxID == "" { + return ErrTransaction + } + hash, err := hex.DecodeString(tx.TxID) + if err != nil { + return err + } + sig, err := key.Sign(hash) + if err != nil { + return err + } + tx.Signature = append(tx.Signature, hex.EncodeToString(sig)) + return nil +} + +// Broadcast sends the signed transaction and returns the raw response. +func (tx *Transaction) Broadcast() (map[string]any, error) { + if tx.client == nil { + return nil, ErrTransaction + } + return tx.client.broadcast(tx) +} + +// Wait polls for the transaction receipt, matching TransactionRet.wait(). +func (tx *Transaction) Wait(timeout, interval time.Duration) (map[string]any, error) { + if tx.client == nil { + return nil, ErrTransaction + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + info, err := tx.client.getTransactionInfo(tx.TxID) + if err == nil { + return info, nil + } + if !errors.Is(err, ErrTransactionNotFound) { + return nil, err + } + time.Sleep(interval) + } + return nil, ErrTransactionNotFound +} + +// JSON returns the compact transaction JSON used for node requests and offline +// mode output, matching json.dumps(txn.to_json(), separators=(",", ":")). +func (tx *Transaction) JSON() string { + b, _ := json.Marshal(tx) + return string(b) +} + +// toJSON returns the transaction as a generic map for node requests. +func (tx *Transaction) toJSON() map[string]any { + var m map[string]any + json.Unmarshal([]byte(tx.JSON()), &m) + return m +} + +// TransactionFromJSON parses a transaction previously exported by JSON(). +func TransactionFromJSON(s string) (*Transaction, error) { + var decoded struct { + TxID string `json:"txID"` + RawData *RawData `json:"raw_data"` + Signature []string `json:"signature"` + Permission any `json:"permission"` + } + if err := json.Unmarshal([]byte(s), &decoded); err != nil { + return nil, err + } + if decoded.RawData == nil { + return nil, ErrTransaction + } + return &Transaction{ + TxID: decoded.TxID, + RawData: decoded.RawData, + Signature: decoded.Signature, + Permission: decoded.Permission, + }, nil +} + +// ParseTransaction parses exported transaction JSON and attaches the client so +// the transaction can be broadcast (mirrors Transaction.from_json + _client). +func (c *Client) ParseTransaction(raw string) (*Transaction, error) { + tx, err := TransactionFromJSON(raw) + if err != nil { + return nil, err + } + tx.client = c + return tx, nil +} diff --git a/tron/values.go b/tron/values.go new file mode 100644 index 0000000..c72b078 --- /dev/null +++ b/tron/values.go @@ -0,0 +1,53 @@ +package tron + +import ( + "encoding/json" + "fmt" + "strconv" +) + +// ValueString coerces a decoded JSON value to a string. It handles the +// json.Number type produced when decoding with UseNumber so large integers are +// preserved exactly. +func ValueString(v any) string { + switch x := v.(type) { + case nil: + return "" + case string: + return x + case json.Number: + return x.String() + case float64: + return strconv.FormatFloat(x, 'f', -1, 64) + case bool: + if x { + return "true" + } + return "false" + default: + return fmt.Sprintf("%v", x) + } +} + +// ValueInt64 coerces a decoded JSON value to an int64. Missing or non-numeric +// values yield 0, matching Python's treatment of absent dict keys. +func ValueInt64(v any) int64 { + switch x := v.(type) { + case json.Number: + if i, err := x.Int64(); err == nil { + return i + } + f, _ := x.Float64() + return int64(f) + case float64: + return int64(x) + case int64: + return x + case int: + return int64(x) + case string: + i, _ := strconv.ParseInt(x, 10, 64) + return i + } + return 0 +} diff --git a/tron/wallet.go b/tron/wallet.go new file mode 100644 index 0000000..fa9d07b --- /dev/null +++ b/tron/wallet.go @@ -0,0 +1,70 @@ +package tron + +import ( + "math/big" + + "code.luxferre.top/luxferre/kisstron/bip32" + "code.luxferre.top/luxferre/kisstron/bip39" + "code.luxferre.top/luxferre/kisstron/secp256k1" +) + +// ValidWordCount reports whether a mnemonic word count is supported. +func ValidWordCount(n int) bool { + return bip39.ValidWordCount(n) +} + +// ValidateMnemonic reports whether a mnemonic is well-formed and checksum-correct. +func ValidateMnemonic(mnemonic string) bool { + return bip39.Validate(mnemonic) +} + +// DeriveFromMnemonic restores a private key from a BIP-39 mnemonic and an +// optional passphrase using the given BIP-32 derivation path. An empty path +// selects the default TRON path (m/44'/195'/0'/0/0). +func DeriveFromMnemonic(mnemonic, passphrase, path string) (*PrivateKey, error) { + if path == "" { + path = bip32.DefaultPath + } + seed := bip39.Seed(mnemonic, passphrase) + key, err := bip32.DerivePath(seed, path) + if err != nil { + return nil, err + } + return newPrivateKeyUnchecked(key), nil +} + +// GenerateWithMnemonic creates a brand new wallet and its mnemonic. The +// returned key and mnemonic belong together and must both be stored. +func GenerateWithMnemonic(passphrase string, numWords int, path string) (*PrivateKey, string, error) { + mnemonic, err := bip39.NewMnemonic(numWords) + if err != nil { + return nil, "", err + } + key, err := DeriveFromMnemonic(mnemonic, passphrase, path) + if err != nil { + return nil, "", err + } + return key, mnemonic, nil +} + +// RecoverAddress recovers the signer's base58check address from a message hash +// and a 65-byte recoverable signature. +func RecoverAddress(hash, sig []byte) (string, error) { + if len(sig) != 65 { + return "", ErrBadSignature + } + r := new(big.Int).SetBytes(sig[0:32]) + ss := new(big.Int).SetBytes(sig[32:64]) + v := sig[64] + if v != 0 && v != 1 { + return "", ErrBadSignature + } + point, err := secp256k1.RecoverPublicKey(hash, &secp256k1.Signature{R: r, S: ss, V: v}) + if err != nil { + return "", err + } + raw := make([]byte, 64) + point.X.FillBytes(raw[0:32]) + point.Y.FillBytes(raw[32:64]) + return (&PublicKey{raw: raw}).ToBase58CheckAddress(), nil +}