mirror of
https://github.com/luxfi/sdk.git
synced 2026-10-30 15:19:23 +00:00
FetchState asked for an X-Chain context and returned the error when the network had none, so a P-only node -- Quasar-era mainnet registers P and C and no X -- could not build a wallet at all. That contradicts the design it was already documented to have: P required, X and C opt-in. The design had been written. "wallet/primary: decomplect — P required, X/C opt-in" landed FetchPState, AttachXChain and the fail-soft matcher, and four commits later "deps: go.mod refresh" -- a commit whose other 30 files are CI workflows and go.mod -- reverted this one file wholesale, taking all three with it and restoring the hard-failing FetchState. Nothing decided that; a refresh clobbered it. So this restores that shape rather than inventing one. FetchPState fetches P and nothing else, which is what a sovereign-L1 spawn, a validator operation or a CreateChainTx caller needs. AttachXChain is opt-in, idempotent, and fail-soft only on "this network has no X" -- every other RPC error still reaches the caller. FetchState is the P+X convenience that calls both. nil XCTX is how "not attached" reads. The attach also pulls X's UTXOs, which the reverted code left to a comment saying they were fetched separately: an X wallet built over an empty set reports insufficient funds whatever the balance. TestFetchState_XChainNotRegistered went from failing to passing, and it could only fail once the file compiled again. TestIsXChainNotEnabled returns with the matcher it covers. Co-authored-by: Hanzo Dev <[email protected]>
569 lines
15 KiB
Go
569 lines
15 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Package primary provides primary network wallet operations.
|
|
package primary
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcutil/bech32"
|
|
"github.com/luxfi/geth/ethclient"
|
|
|
|
luxconstants "github.com/luxfi/constants"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/math/set"
|
|
"github.com/luxfi/rpc"
|
|
"github.com/luxfi/sdk/constants"
|
|
sdkinfo "github.com/luxfi/sdk/info"
|
|
"github.com/luxfi/sdk/platformvm"
|
|
"github.com/luxfi/sdk/wallet/chain/p"
|
|
"github.com/luxfi/sdk/wallet/chain/x"
|
|
lux "github.com/luxfi/utxo"
|
|
|
|
gethcommon "github.com/luxfi/geth/common"
|
|
pbuilder "github.com/luxfi/sdk/wallet/chain/p/builder"
|
|
xbuilder "github.com/luxfi/sdk/wallet/chain/x/builder"
|
|
walletcommon "github.com/luxfi/sdk/wallet/primary/common"
|
|
)
|
|
|
|
const (
|
|
MainnetAPIURI = "https://api.lux.network"
|
|
TestnetAPIURI = "https://api.lux-test.network"
|
|
LocalAPIURI = "http://localhost:9630"
|
|
|
|
fetchLimit = 1024
|
|
|
|
// Retry configuration for transient network errors
|
|
maxRetries = 5
|
|
retryBaseWait = 100 * time.Millisecond
|
|
)
|
|
|
|
// perform their own assertions.
|
|
var (
|
|
_ UTXOClient = (*platformvm.Client)(nil)
|
|
_ UTXOClient = (*XClient)(nil)
|
|
)
|
|
|
|
type UTXOClient interface {
|
|
GetAtomicUTXOs(
|
|
ctx context.Context,
|
|
addrs []ids.ShortID,
|
|
sourceChain string,
|
|
limit uint32,
|
|
startAddress ids.ShortID,
|
|
startUTXOID ids.ID,
|
|
options ...rpc.Option,
|
|
) ([][]byte, ids.ShortID, ids.ID, error)
|
|
}
|
|
|
|
// XClient is a client for interacting with the X-Chain
|
|
type XClient struct {
|
|
requester rpc.EndpointRequester
|
|
networkID uint32
|
|
blockchainID ids.ID
|
|
}
|
|
|
|
// NewXClient returns a new X-Chain client
|
|
func NewXClient(uri, chainAlias string) *XClient {
|
|
return &XClient{
|
|
requester: rpc.NewEndpointRequester(
|
|
constants.Chain(uri, chainAlias),
|
|
),
|
|
}
|
|
}
|
|
|
|
// NewXClientWithContext returns a new X-Chain client with context information
|
|
// required for proper address formatting and UTXO queries.
|
|
// Note: Uses "X" alias for the endpoint (more reliable) but keeps blockchainID
|
|
// for address formatting in UTXO queries.
|
|
func NewXClientWithContext(uri string, networkID uint32, blockchainID ids.ID) *XClient {
|
|
return &XClient{
|
|
requester: rpc.NewEndpointRequester(
|
|
constants.Chain(uri, "X"), // Use alias, not blockchain ID (avoids EOF issues)
|
|
),
|
|
networkID: networkID,
|
|
blockchainID: blockchainID,
|
|
}
|
|
}
|
|
|
|
// SetContext sets the network context for address formatting
|
|
func (c *XClient) SetContext(networkID uint32, blockchainID ids.ID) {
|
|
c.networkID = networkID
|
|
c.blockchainID = blockchainID
|
|
}
|
|
|
|
// GetAtomicUTXOs implements UTXOClient.
|
|
// Queries the X-chain for UTXOs controlled by the given addresses.
|
|
func (c *XClient) GetAtomicUTXOs(
|
|
ctx context.Context,
|
|
addrs []ids.ShortID,
|
|
sourceChain string,
|
|
limit uint32,
|
|
startAddress ids.ShortID,
|
|
startUTXOID ids.ID,
|
|
options ...rpc.Option,
|
|
) ([][]byte, ids.ShortID, ids.ID, error) {
|
|
// Format addresses using blockchain ID prefix for local networks
|
|
formattedAddrs := make([]string, len(addrs))
|
|
hrp := luxconstants.GetHRP(c.networkID)
|
|
for i, addr := range addrs {
|
|
// Use blockchain ID as chain prefix for proper address formatting
|
|
chainPrefix := c.blockchainID.String()
|
|
if chainPrefix == "" || c.blockchainID == ids.Empty {
|
|
chainPrefix = "X" // fallback to "X" for compatibility
|
|
}
|
|
addrStr, err := formatAddressWithChain(chainPrefix, hrp, addr[:])
|
|
if err != nil {
|
|
return nil, ids.ShortID{}, ids.Empty, fmt.Errorf("failed to format address: %w", err)
|
|
}
|
|
formattedAddrs[i] = addrStr
|
|
}
|
|
|
|
// Query exchangevm.getUTXOs (Lux X-chain uses exchangevm service prefix)
|
|
res := &getUTXOsReply{}
|
|
err := c.requester.SendRequest(ctx, "exchangevm.getUTXOs", &getUTXOsArgs{
|
|
Addresses: formattedAddrs,
|
|
SourceChain: sourceChain,
|
|
Limit: limit,
|
|
Encoding: "hex",
|
|
}, res, options...)
|
|
if err != nil {
|
|
// If exchangevm service not available, try xvm for compatibility
|
|
err2 := c.requester.SendRequest(ctx, "xvm.getUTXOs", &getUTXOsArgs{
|
|
Addresses: formattedAddrs,
|
|
SourceChain: sourceChain,
|
|
Limit: limit,
|
|
Encoding: "hex",
|
|
}, res, options...)
|
|
if err2 != nil {
|
|
return nil, ids.ShortID{}, ids.Empty, fmt.Errorf("failed to get UTXOs: exchangevm error: %w, xvm error: %v", err, err2)
|
|
}
|
|
}
|
|
|
|
// Decode UTXOs from hex
|
|
utxos := make([][]byte, len(res.UTXOs))
|
|
for i, utxo := range res.UTXOs {
|
|
utxoBytes, err := hexDecode(utxo)
|
|
if err != nil {
|
|
return nil, ids.ShortID{}, ids.Empty, fmt.Errorf("failed to decode UTXO %d: %w", i, err)
|
|
}
|
|
utxos[i] = utxoBytes
|
|
}
|
|
|
|
// Parse end index for pagination
|
|
var endAddr ids.ShortID
|
|
var endUTXO ids.ID
|
|
if res.EndIndex.Address != "" {
|
|
_, _, addrBytes, err := parseAddress(res.EndIndex.Address)
|
|
if err == nil && len(addrBytes) == 20 {
|
|
copy(endAddr[:], addrBytes)
|
|
}
|
|
}
|
|
if res.EndIndex.UTXO != "" {
|
|
endUTXO, _ = ids.FromString(res.EndIndex.UTXO)
|
|
}
|
|
|
|
return utxos, endAddr, endUTXO, nil
|
|
}
|
|
|
|
// getUTXOsArgs are the arguments to xvm.getUTXOs
|
|
type getUTXOsArgs struct {
|
|
Addresses []string `json:"addresses"`
|
|
SourceChain string `json:"sourceChain,omitempty"`
|
|
Limit uint32 `json:"limit,omitempty"`
|
|
StartIndex struct {
|
|
Address string `json:"address,omitempty"`
|
|
UTXO string `json:"utxo,omitempty"`
|
|
} `json:"startIndex,omitempty"`
|
|
Encoding string `json:"encoding,omitempty"`
|
|
}
|
|
|
|
// getUTXOsReply is the response from xvm.getUTXOs
|
|
type getUTXOsReply struct {
|
|
NumFetched string `json:"numFetched"`
|
|
UTXOs []string `json:"utxos"`
|
|
EndIndex struct {
|
|
Address string `json:"address"`
|
|
UTXO string `json:"utxo"`
|
|
} `json:"endIndex"`
|
|
Encoding string `json:"encoding"`
|
|
}
|
|
|
|
type LUXState struct {
|
|
PClient *platformvm.Client
|
|
PCTX *pbuilder.Context
|
|
// X-Chain is opt-in: nil until AttachXChain, and still nil after it on a
|
|
// network that registers no X at all. nil is the one way to read "not
|
|
// attached" -- no sentinel context stands in for it.
|
|
XClient *XClient
|
|
XCTX *xbuilder.Context
|
|
UTXOs walletcommon.UTXOs
|
|
|
|
// The node and addresses the P-Chain fetch used, kept so an opt-in attach
|
|
// reaches the same ones.
|
|
uri string
|
|
addrs []ids.ShortID
|
|
}
|
|
|
|
// FetchPState fetches the P-Chain client, context and UTXOs, and nothing else.
|
|
//
|
|
// P is the only chain a sovereign-L1 spawn (CreateChainTx), a validator
|
|
// operation or a primary-network transaction needs. X (UTXO asset transfers)
|
|
// and C (EVM contracts) are opt-in: call AttachXChain on the state it returns
|
|
// if you want them. Sovereign-L1 callers want this function.
|
|
func FetchPState(
|
|
ctx context.Context,
|
|
uri string,
|
|
addrs set.Set[ids.ShortID],
|
|
) (*LUXState, error) {
|
|
infoClient := sdkinfo.NewClient(uri)
|
|
pClient := platformvm.NewClient(uri)
|
|
|
|
pCTX, err := p.NewContextFromClients(ctx, infoClient, pClient)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Without the network ID the client formats bech32 against networkID=0,
|
|
// which falls back to the "custom" HRP.
|
|
pClient.SetNetworkID(pCTX.NetworkID)
|
|
|
|
utxos := walletcommon.NewUTXOs()
|
|
addrList := addrs.List()
|
|
if err := AddAllUTXOs(
|
|
ctx,
|
|
utxos,
|
|
pClient,
|
|
luxconstants.PlatformChainID,
|
|
luxconstants.PlatformChainID,
|
|
addrList,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &LUXState{
|
|
PClient: pClient,
|
|
PCTX: pCTX,
|
|
UTXOs: utxos,
|
|
uri: uri,
|
|
addrs: addrList,
|
|
}, nil
|
|
}
|
|
|
|
// AttachXChain adds the X-Chain client and context to the state.
|
|
//
|
|
// It is fail-soft on purpose. A network that registers no X-Chain -- the
|
|
// Quasar-era P+C mainnet -- is a shape this SDK supports, so there the attach
|
|
// is a no-op that leaves XCTX nil rather than failing the whole fetch. Every
|
|
// other RPC error is the caller's to see. Calling it twice is a no-op.
|
|
func (s *LUXState) AttachXChain(ctx context.Context) error {
|
|
if s.XCTX != nil {
|
|
return nil
|
|
}
|
|
|
|
const (
|
|
baseTxFee = uint64(1000000) // 0.001 LUX
|
|
createAssetTxFee = uint64(10000000) // 0.01 LUX
|
|
)
|
|
|
|
xCTX, err := x.NewContextFromClients(
|
|
ctx,
|
|
sdkinfo.NewClient(s.uri),
|
|
s.PCTX.UTXOAssetID,
|
|
baseTxFee,
|
|
createAssetTxFee,
|
|
)
|
|
if err != nil {
|
|
if isXChainNotEnabled(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
s.XCTX = xCTX
|
|
s.XClient = NewXClientWithContext(s.uri, s.PCTX.NetworkID, xCTX.BlockchainID)
|
|
|
|
// Without these the X wallet is built over an empty set, and every
|
|
// transaction reports insufficient funds whatever the balance.
|
|
return AddAllUTXOs(ctx, s.UTXOs, s.XClient, xCTX.BlockchainID, xCTX.BlockchainID, s.addrs)
|
|
}
|
|
|
|
// FetchState fetches P and then attaches X, for callers that want both. New
|
|
// callers should say which chains they need: FetchPState, then AttachXChain.
|
|
func FetchState(
|
|
ctx context.Context,
|
|
uri string,
|
|
addrs set.Set[ids.ShortID],
|
|
) (*LUXState, error) {
|
|
state, err := FetchPState(ctx, uri, addrs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := state.AttachXChain(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
// isXChainNotEnabled reports whether err is the info service saying this
|
|
// network has no X-Chain. The service answers with a free-form string rather
|
|
// than a typed error, so this reads the message; revisit it if
|
|
// info.getBlockchainID ever grows a typed path.
|
|
func isXChainNotEnabled(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
return strings.Contains(msg, "there is no id with alias: x") ||
|
|
strings.Contains(msg, "no chain with alias")
|
|
}
|
|
|
|
// Account is what an EVM address holds: a balance and a nonce, read off the
|
|
// client below. It carried no wire of its own and lived in a wallet package
|
|
// that no longer exists.
|
|
type Account struct {
|
|
Balance *big.Int
|
|
Nonce uint64
|
|
}
|
|
|
|
type EthState struct {
|
|
Client *ethclient.Client
|
|
Accounts map[gethcommon.Address]*Account
|
|
}
|
|
|
|
func FetchEthState(
|
|
ctx context.Context,
|
|
uri string,
|
|
addrs set.Set[gethcommon.Address],
|
|
) (*EthState, error) {
|
|
client, err := ethclient.Dial(constants.Chain(uri, "C") + "/rpc")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
accounts := make(map[gethcommon.Address]*Account, addrs.Len())
|
|
for addr := range addrs {
|
|
// Convert ethereum address to geth address
|
|
gethAddr := gethcommon.Address(addr)
|
|
balance, err := client.BalanceAt(ctx, gethAddr, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nonce, err := client.NonceAt(ctx, gethAddr, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
accounts[addr] = &Account{
|
|
Balance: balance,
|
|
Nonce: nonce,
|
|
}
|
|
}
|
|
return &EthState{
|
|
Client: client,
|
|
Accounts: accounts,
|
|
}, nil
|
|
}
|
|
|
|
// isRetryableError checks if an error is transient and should be retried
|
|
func isRetryableError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
errStr := err.Error()
|
|
// Check for EOF errors (connection closed by server)
|
|
if err == io.EOF || strings.Contains(errStr, "EOF") {
|
|
return true
|
|
}
|
|
// Check for connection reset
|
|
if strings.Contains(errStr, "connection reset") {
|
|
return true
|
|
}
|
|
// Check for temporary network errors
|
|
if strings.Contains(errStr, "temporary failure") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AddAllUTXOs fetches all the UTXOs referenced by [addresses] that were sent
|
|
// from [sourceChainID] to [destinationChainID] from the [client]. It then uses
|
|
// [codec] to parse the returned UTXOs and it adds them into [utxos]. If [ctx]
|
|
// expires, then the returned error will be immediately reported.
|
|
func AddAllUTXOs(
|
|
ctx context.Context,
|
|
utxos walletcommon.UTXOs,
|
|
client UTXOClient,
|
|
sourceChainID ids.ID,
|
|
destinationChainID ids.ID,
|
|
addrs []ids.ShortID,
|
|
) error {
|
|
// When sourceChainID == destinationChainID, we're fetching regular UTXOs
|
|
// on the same chain, not atomic UTXOs from cross-chain transfers.
|
|
// The platformvm service expects empty string for regular UTXO fetches,
|
|
// because at runtime the VM's chainID may differ from the constant.
|
|
var sourceChainIDStr string
|
|
if sourceChainID != destinationChainID {
|
|
sourceChainIDStr = sourceChainID.String()
|
|
}
|
|
var (
|
|
startAddr ids.ShortID
|
|
startUTXO ids.ID
|
|
)
|
|
for {
|
|
var utxosBytes [][]byte
|
|
var endAddr ids.ShortID
|
|
var endUTXO ids.ID
|
|
var err error
|
|
|
|
// Retry loop for transient network errors
|
|
for retry := 0; retry <= maxRetries; retry++ {
|
|
utxosBytes, endAddr, endUTXO, err = client.GetAtomicUTXOs(
|
|
ctx,
|
|
addrs,
|
|
sourceChainIDStr,
|
|
fetchLimit,
|
|
startAddr,
|
|
startUTXO,
|
|
)
|
|
if err == nil {
|
|
break
|
|
}
|
|
if !isRetryableError(err) {
|
|
return fmt.Errorf("GetAtomicUTXOs: %w", err)
|
|
}
|
|
if retry == maxRetries {
|
|
return fmt.Errorf("GetAtomicUTXOs failed after %d retries: %w", maxRetries, err)
|
|
}
|
|
// Wait with exponential backoff before retrying
|
|
wait := retryBaseWait * time.Duration(1<<uint(retry))
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(wait):
|
|
// Continue to next retry
|
|
}
|
|
}
|
|
|
|
for _, utxoBytes := range utxosBytes {
|
|
// The UTXO decodes itself from its own wire bytes; there is no
|
|
// codec to hand it. ParseUTXO is strict, so a genesis UTXO with
|
|
// trailing bytes is rejected here rather than tolerated.
|
|
utxo, err := lux.ParseUTXO(utxoBytes)
|
|
if err != nil {
|
|
continue // unparseable
|
|
}
|
|
if utxo.AssetID() == ids.Empty {
|
|
continue // invalid UTXO
|
|
}
|
|
if err := utxos.AddUTXO(ctx, sourceChainID, destinationChainID, utxo); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if len(utxosBytes) < fetchLimit {
|
|
break
|
|
}
|
|
|
|
// Update the vars to query the next page of UTXOs.
|
|
startAddr = endAddr
|
|
startUTXO = endUTXO
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// formatAddressWithChain formats an address with chain prefix and bech32 encoding
|
|
func formatAddressWithChain(chainPrefix, hrp string, addrBytes []byte) (string, error) {
|
|
// Convert to bech32 with 5-bit groups
|
|
conv, err := bech32.ConvertBits(addrBytes, 8, 5, true)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to convert address bits: %w", err)
|
|
}
|
|
encoded, err := bech32.Encode(hrp, conv)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to bech32 encode: %w", err)
|
|
}
|
|
return fmt.Sprintf("%s-%s", chainPrefix, encoded), nil
|
|
}
|
|
|
|
// parseAddress parses a formatted address like "X-lux1..." into its components
|
|
func parseAddress(addr string) (chainPrefix, hrp string, addrBytes []byte, err error) {
|
|
// Split on "-" to get chain prefix
|
|
parts := make([]string, 0, 2)
|
|
idx := 0
|
|
for i, c := range addr {
|
|
if c == '-' {
|
|
parts = append(parts, addr[idx:i])
|
|
idx = i + 1
|
|
if len(parts) == 1 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if idx < len(addr) {
|
|
parts = append(parts, addr[idx:])
|
|
}
|
|
|
|
if len(parts) != 2 {
|
|
return "", "", nil, fmt.Errorf("invalid address format: %s", addr)
|
|
}
|
|
chainPrefix = parts[0]
|
|
bech32Addr := parts[1]
|
|
|
|
// Decode bech32
|
|
hrp, data, err := bech32.Decode(bech32Addr)
|
|
if err != nil {
|
|
return "", "", nil, fmt.Errorf("failed to decode bech32: %w", err)
|
|
}
|
|
|
|
// Convert from 5-bit to 8-bit groups
|
|
addrBytes, err = bech32.ConvertBits(data, 5, 8, false)
|
|
if err != nil {
|
|
return "", "", nil, fmt.Errorf("failed to convert address bits: %w", err)
|
|
}
|
|
|
|
return chainPrefix, hrp, addrBytes, nil
|
|
}
|
|
|
|
// hexDecode decodes a hex string (with or without 0x prefix)
|
|
func hexDecode(s string) ([]byte, error) {
|
|
if len(s) >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X') {
|
|
s = s[2:]
|
|
}
|
|
return hexDecodeString(s)
|
|
}
|
|
|
|
// hexDecodeString decodes a hex string without 0x prefix
|
|
func hexDecodeString(s string) ([]byte, error) {
|
|
if len(s)%2 != 0 {
|
|
s = "0" + s
|
|
}
|
|
b := make([]byte, len(s)/2)
|
|
for i := 0; i < len(b); i++ {
|
|
h := hexValue(s[i*2])
|
|
l := hexValue(s[i*2+1])
|
|
if h == 255 || l == 255 {
|
|
return nil, fmt.Errorf("invalid hex character at position %d", i*2)
|
|
}
|
|
b[i] = h<<4 | l
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func hexValue(c byte) byte {
|
|
switch {
|
|
case '0' <= c && c <= '9':
|
|
return c - '0'
|
|
case 'a' <= c && c <= 'f':
|
|
return c - 'a' + 10
|
|
case 'A' <= c && c <= 'F':
|
|
return c - 'A' + 10
|
|
default:
|
|
return 255
|
|
}
|
|
}
|