This commit is contained in:
Luxferre
2026-09-19 18:26:07 +03:00
parent 8ae332e74d
commit e9826b1ae2
28 changed files with 3417 additions and 1 deletions
+118
View File
@@ -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
}
+56
View File
@@ -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")
}
}
+146
View File
@@ -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)
+90
View File
@@ -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")
}
}
+54
View File
@@ -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)
}
}
+39
View File
@@ -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")
}
}
+3
View File
@@ -0,0 +1,3 @@
module code.luxferre.top/luxferre/kisstron
go 1.24
+150
View File
@@ -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)
+70
View File
@@ -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")
}
}
+6 -1
View File
@@ -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': {
+309
View File
@@ -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 <https://unlicense.org>\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
}
+385
View File
@@ -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"
}
+292
View File
@@ -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
}
+130
View File
@@ -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")
}
}
+121
View File
@@ -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)
}
+151
View File
@@ -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
}
+109
View File
@@ -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
}
+43
View File
@@ -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}
}
+97
View File
@@ -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(),
}
}
+117
View File
@@ -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())
}
}
+244
View File
@@ -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
}
+67
View File
@@ -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)
}
})
}
}
+158
View File
@@ -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 {<method>} 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
}
+68
View File
@@ -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)
}
})
}
}
+65
View File
@@ -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]
}
+206
View File
@@ -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
}
+53
View File
@@ -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
}
+70
View File
@@ -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
}