This commit is contained in:
Luxferre
2026-09-19 18:26:07 +03:00
parent 8ae332e74d
commit e9826b1ae2
28 changed files with 3417 additions and 1 deletions
+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
}