Files
zeekayandHanzo Dev d00b9f0f6a A network with no X-Chain is a shape we support, not a failure
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]>
2026-08-30 15:26:36 -07:00

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
}
}