Compare commits

...
2 Commits
Author SHA1 Message Date
Luxferre 970cd56ba5 go port 2026-09-19 18:26:25 +03:00
Luxferre e9826b1ae2 go port 2026-09-19 18:26:07 +03:00
30 changed files with 3517 additions and 215 deletions
-214
View File
@@ -1,214 +0,0 @@
kisstron: KISS-friendly Tron CLI wallet
---------------------------------------
This is a simple, no-nonsense Tron cryptocurrency wallet written in Python 3
on top of the open-source tronpy library. The kisstron project aims to enable
CLI users to easily use Tron cryptocurrency, including stablecoins like USDT
or USDC, without all the hassle of "native" (Java-based) Tron's wallet-cli or
similar third-party projects.
== Features ==
* Support for mainnet Tron as well as Nile and Shasta testnets
* Small codebase size that can be easily audited
* Full BIP-39 support for mnemonic wallet generation and import (English only)
* Account info display: TRX/USDT/USDC balances, frozen assets, remaining
energy and bandwidth
* Sending TRX/USDT/USDC on all supported networks
* Freezing (for energy) and unfreezing TRX on all supported networks
* Ability to connect to custom nodes or TronGrid API (with your own API key)
* Offline mode for send/freeze/unfreeze transactions
* No filesystem interaction (that's a feature, not a bug: see the FAQ section)
* Full scriptability: send/freeze/unfreeze transactions require manual approval
but it actually can be turned off with a special commandline parameter for the
kisstron wallet to be used in various automation tasks
* The kisstron.py file can be used as a library itself for different projects
== Dependency setup ==
First, install Python 3, pip and the required dependencies (from this dir):
pip install -r requirements.txt
In some distributions, you may need to run pip3 instead of pip. Also, your
distribution might not contain the required development packages. For instance,
in Alpine Linux, you might have to run the following command first (as root):
apk add python3-dev libtool autoconf automake musl-dev libffi-dev
Optionally, make the kisstron.py file executable:
chmod +x kisstron.py
After everything is set up, you should be able to run the help screen:
./kisstron.py -h
== Usage ==
Now, let's cover typical kisstron wallet usage scenarios.
-- Generate a new wallet --
./kisstron.py new trx [-pp "passphrase"] [-wc word_count] [-dp derivation_path]
This will generate a new Tron wallet and display all its information. You must
then store this information in a secure place.
If -pp option is specified, your custom passphrase will be used to encrypt the
mnemonic phrase. If -wc option is specified, you can set to generate 15, 18, 21
or 24 words of mnemonic instead of 12. If -dp option is specified, you can set
a non-standard BIP-39 derivation path. It is generally NOT recommended to ever
use this option unless you are an expert.
-- Import an existing wallet from a mnemonic --
./kisstron.py import trx [-pp "passphrase"] [-dp derivation_path]
This will ask you to enter your BIP-39 mnemonic and import your Tron wallet by
recovering and displaying all information about it. You must then store this
information in a secure place.
If -pp option is specified, your custom passphrase will be used to decrypt the
mnemonic phrase. The -dp option is the same as for the "new trx" subcommand, so
the same precautions apply.
Note that entering a wrong passphrase won't generate an error: kisstron will
just restore a different wallet from the same mnemonic, so be careful.
-- Network options --
For any of the further operations in this section, the following options can
be added and are quite important:
* -net selects the network: mainnet (default), nile, shasta
* -node allows to specify custom Tron node URL (must be HTTP URL, not RPC)
* -tgkey allows to specify custom TronGrid API key (overrides -node)
* -fee allows to set custom transaction fee limit (in TRX, 100 by default) if
operating on the mainnet (relevant for send, fre and unf subcommands)
These options will be marked as [netopts] in the further reference.
Note that -tgkey is only relevant for mainnet and has no effect on testnets.
-- Displaying account information --
Display account information based on any Tron address:
./kisstron.py info trx -addr tron_address [netopts]
Display your account information based on your private key (in hex):
./kisstron.py info trx -pk private_key [netopts]
Either of these commands will display the following information:
* Tron address (in Base58check format)
* Balances in TRX, USDT and USDC
* Frozen TRX assets (if any)
* Remaining bandwidth amount
* Remaining energy amount
If you supply both -pk and -addr to "info trx" subcommand, -pk will take
precedence.
-- Sending funds --
Send TRX:
./kisstron.py send trx -addr dest_addr -amt amount -pk private_key [netopts]
Send USDT:
./kisstron.py send usdt -addr dest_addr -amt amount -pk private_key [netopts]
Send USDC:
./kisstron.py send usdc -addr dest_addr -amt amount -pk private_key [netopts]
Any of these commands will ask you for confirmation by entering "yes" and
pressing Enter to send the transaction. If you don't want to do this or use
the send subcommands in some kind of automation, append -nc flag to them.
The amount is entered in floating point TRX/USDT/USDC values respectively,
not in minimal units.
When transfering USDT/USDC, you may get error messages related to lack of
energy to conduct the transaction. Please refer to the next section of TRX
freezing to get energy.
-- Freezing and unfreezing TRX funds --
Freeze TRX for energy:
./kisstron.py fre trx -amt amount -pk private_key [netopts]
Unfreeze TRX:
./kisstron.py unf trx -amt amount -pk private_key [netopts]
Any of these commands will ask you for confirmation by entering "yes" and
pressing Enter to send the transaction. If you don't want to do this or use
the subcommands in some kind of automation, append -nc flag to them.
Again, the amount of TRX is entered as a floating point value.
Keep in mind that it takes up to 72 hours to unfreeze your TRX assets.
-- Offline operations --
The send trx, send usdt, send usdc, fre trx and unf trx subcommands support the
-o flag that, after the "Success:" line, outputs a JSON of a signed transaction.
You can copy or otherwise transfer this output to a different machine running
kisstron and broadcast it to the network with bro trx subcommand:
./kisstron.py bro trx [netopts]
You can then paste the transaction JSON into the input field, press Enter and,
unless -nc flag is passed too, confirm sending the transaction by entering yes.
The transaction will be broadcast and processed as usual.
Keep in mind that Tron transactions expire very quickly, so you have a limited
time window between signing and publishing them.
== FAQ ==
- Can I trust this wallet?
The author trusts it. In fact, kisstron was created because of lack of decent
FOSS Tron wallets for Linux/BSD desktop operating systems. However, it cannot
be considered production-ready as it hasn't undergone rigorous testing and
independent audit. The kisstron codebase is small and well-commented though,
so you can trust it exactly to the same extent as you trust the underlying
tronpy library and its dependencies.
You can study tronpy source code here: https://github.com/andelf/tronpy
- Why are TRC-20 tokens hardcoded?
To simplify the logic and avoid any user mistakes when manually pasting token
contract addresses. If you need to add custom token support, you can always
modify the KT_TRC20_TOKENS dictionary in the source code directly.
- Why doesn't kisstron do anything to keep private keys and passphrases secure?
Because it only cares about interacting with Tron network and creating or
restoring your wallets. For key/password management, there are dedicated tools
like pass (https://www.passwordstore.org/) that do their job extremely well.
For example, instead of -pk private_key, you could write -pk $(pass kt/pkey) or
something like that if you stored your wallet key under kt/pkey path in pass.
This way, your keys can stay secure without adding any filesystem interaction
to kisstron itself, reducing the overall attack surface.
- Does kisstron use any remote API to sign transactions?
No. The underlying tronpy library signs all transactions offline, so the
private key never leaves your machine. It uses libsecp256k1 via coincurve
library binding for transaction signing.
You can also use offline mode to split signing and sending transactions, as
shown in this document.
- Why doesn't kisstron support TRC-10, multisig, witnesses, history etc?
Support for some of these things may come in the future. But kisstron was
created as a minimum viable wallet to operate on TRX and select stablecoins. It
adheres to the KISS principle ("keep it simple, stupid"), hence the name.
== Credits ==
Created by Luxferre in 2024, released into public domain with no warranties.
Made in Ukraine.
+100
View File
@@ -0,0 +1,100 @@
# kisstron (Go)
A KISS-oriented TRON CLI wallet, ported from Python to Go with **zero third-party
dependencies** (Go standard library only). It doubles as an importable library.
## Install
```sh
go install code.luxferre.top/luxferre/kisstron@latest
```
This builds the `kisstron` CLI into `$(go env GOPATH)/bin`.
## CLI usage
The command-line interface is identical to the original Python `kisstron.py`:
```sh
kisstron new trx
kisstron import trx
kisstron info trx -addr <tron-address>
kisstron info trx -pk <hex-private-key> -net nile
kisstron send trx -addr <dest> -amt 10 -pk <hex-key>
kisstron send usdt -addr <dest> -amt 5 -pk <hex-key> -nc
kisstron fre trx -amt 100 -pk <hex-key>
kisstron unf trx -amt 100 -pk <hex-key>
kisstron send trx -addr <dest> -amt 10 -pk <hex-key> -o # offline: emit signed JSON
kisstron bro trx # broadcast JSON from stdin
```
Options: `-net/--network`, `-node/--tron-node`, `-tgkey/--trongrid-api-key`,
`-fee/--fee-limit`, `-addr/--address`, `-amt/--amount`, `-pk/--private-key`,
`-o/--offline`, `-pp/--passphrase`, `-wc/--words-count`, `-dp/--derivation-path`,
`-nc/--no-confirm`. See `kisstron -h` for details.
## Library usage
All packages live at the module root and can be imported directly:
| Package | Purpose |
| --- | --- |
| `code.luxferre.top/luxferre/kisstron/tron` | Keys, addresses, TRON client, transactions, TRC20, wallet helpers |
| `code.luxferre.top/luxferre/kisstron/keccak` | Legacy Keccak-256 |
| `code.luxferre.top/luxferre/kisstron/base58` | Base58 / base58check |
| `code.luxferre.top/luxferre/kisstron/secp256k1` | secp256k1 curve + recoverable ECDSA |
| `code.luxferre.top/luxferre/kisstron/bip39` | BIP-39 mnemonics and seed derivation |
| `code.luxferre.top/luxferre/kisstron/bip32` | BIP-32 CKDpriv derivation |
| `code.luxferre.top/luxferre/kisstron/ops` | High-level wallet operations |
| `code.luxferre.top/luxferre/kisstron/cli` | Result formatting and confirmation |
Example: restore a wallet and convert an address.
```go
package main
import (
"fmt"
"code.luxferre.top/luxferre/kisstron/tron"
)
func main() {
key, err := tron.DeriveFromMnemonic("cloth print knife public box ... rose lounge", "", "")
if err != nil {
panic(err)
}
fmt.Println(key.Pub.ToBase58CheckAddress())
addr, _ := tron.ToHexAddress(key.Pub.ToBase58CheckAddress())
fmt.Println(addr)
}
```
Example: query the chain and send TRX.
```go
client := tron.NewClient("nile", tron.DefaultEndpoint("nile"), tron.FeeLimitTestnet, "")
info, err := client.GetAccount("TTynhnM88m4C9Wohm2zT6oiYbV7hC8qUJm")
if err != nil {
panic(err)
}
fmt.Println(info["balance"])
key, _ := tron.PrivateKeyFromHex("<hex>")
builder, _ := client.Transfer(key.Pub.ToBase58CheckAddress(), "<dest>", 1_000_000)
tx, _ := builder.Build()
_ = tx.Sign(key)
receipt, _ := tx.Broadcast()
fmt.Println(receipt["txid"])
```
## Notes
- **Zero dependencies**: `go.mod` has no `require` block and there is no `go.sum`.
Every cryptographic primitive is implemented on the Go standard library.
- Source uses 2-space indentation (do not run `gofmt`, which would convert it to tabs).
- The original Python implementation is retained as `kisstron.py` for reference.
- The Go port reproduces the original output byte-for-byte, including a latent
bandwidth calculation quirk in the Python original (documented in `ops/ops.go`).
- Created by Luxferre, released into the public domain with no warranties.
+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
}