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]>
282 lines
9.2 KiB
Go
282 lines
9.2 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package primary
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/math/set"
|
|
)
|
|
|
|
// TestIsXChainNotEnabled checks the matcher that decides whether a missing
|
|
// X-Chain is this network's shape or a real failure. It must catch the
|
|
// canonical info-service string case-insensitively, and the sibling phrasing
|
|
// some node releases emit, and nothing else.
|
|
func TestIsXChainNotEnabled(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{"nil error", nil, false},
|
|
{"canonical message", fmt.Errorf("there is no ID with alias: X"), true},
|
|
{"lowercased", fmt.Errorf("there is no id with alias: x"), true},
|
|
{"sibling phrasing", fmt.Errorf("no chain with alias X"), true},
|
|
{"unrelated error", fmt.Errorf("connection refused"), false},
|
|
{"network timeout", fmt.Errorf("dial tcp: i/o timeout"), false},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
require.Equal(t, tt.want, isXChainNotEnabled(tt.err))
|
|
})
|
|
}
|
|
}
|
|
|
|
// fakeInfoServer is a minimal JSON-RPC 2.0 server impersonating an
|
|
// /v1/info endpoint exposing GetNetworkID, GetBlockchainID, GetTxFee.
|
|
// Behavior is controlled by xChainServed: when false, info.getBlockchainID
|
|
// for alias "X" responds with the canonical "no ID with alias: X" error.
|
|
type fakeInfoServer struct {
|
|
mu sync.Mutex
|
|
xChainServed bool
|
|
pChainAlias string
|
|
}
|
|
|
|
func (f *fakeInfoServer) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
defer r.Body.Close()
|
|
|
|
var req struct {
|
|
ID int `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
type rpcErr struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
type rpcResp struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID int `json:"id"`
|
|
Result interface{} `json:"result,omitempty"`
|
|
Error *rpcErr `json:"error,omitempty"`
|
|
}
|
|
|
|
resp := rpcResp{JSONRPC: "2.0", ID: req.ID}
|
|
switch req.Method {
|
|
case "info.getNetworkID":
|
|
resp.Result = map[string]interface{}{"networkID": 1}
|
|
case "info.getBlockchainID":
|
|
var args struct {
|
|
Alias string `json:"alias"`
|
|
}
|
|
_ = json.Unmarshal(req.Params, &args)
|
|
f.mu.Lock()
|
|
served := f.xChainServed
|
|
f.mu.Unlock()
|
|
if args.Alias == "X" && !served {
|
|
resp.Error = &rpcErr{
|
|
Code: -32000,
|
|
Message: fmt.Sprintf("there is no ID with alias: %s", args.Alias),
|
|
}
|
|
} else {
|
|
// arbitrary deterministic ID for tests
|
|
resp.Result = map[string]interface{}{
|
|
"blockchainID": "2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM",
|
|
}
|
|
}
|
|
case "info.getTxFee":
|
|
resp.Result = map[string]interface{}{
|
|
"createAssetTxFee": "10000000",
|
|
"createNetworkTxFee": "1000000000",
|
|
"createChainTxFee": "1000000000",
|
|
"addPrimaryNetworkValidatorFee": "0",
|
|
"addPrimaryNetworkDelegatorFee": "0",
|
|
"addNetworkValidatorFee": "1000000",
|
|
"addNetworkDelegatorFee": "1000000",
|
|
"txFee": "1000000",
|
|
}
|
|
default:
|
|
resp.Error = &rpcErr{Code: -32601, Message: fmt.Sprintf("method %s not found", req.Method)}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func (f *fakeInfoServer) handlePChain(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
defer r.Body.Close()
|
|
var req struct {
|
|
ID int `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
type rpcErr struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
type rpcResp struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID int `json:"id"`
|
|
Result interface{} `json:"result,omitempty"`
|
|
Error *rpcErr `json:"error,omitempty"`
|
|
}
|
|
resp := rpcResp{JSONRPC: "2.0", ID: req.ID}
|
|
switch req.Method {
|
|
case "platform.getUTXOs":
|
|
// no UTXOs for the test wallet. EndIndex must hold a parseable
|
|
// bech32 address; we return the zero address under the "lux" HRP
|
|
// (computed offline via address.Format("P", "lux", 20-byte zero)).
|
|
// numFetched < fetchLimit so the wallet's pagination loop exits
|
|
// on the first reply.
|
|
resp.Result = map[string]interface{}{
|
|
"numFetched": "0",
|
|
"utxos": []string{},
|
|
"endIndex": map[string]string{
|
|
"address": "P-lux1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqquc4vdn",
|
|
"utxo": "11111111111111111111111111111111LpoYY",
|
|
},
|
|
"encoding": "hex",
|
|
}
|
|
case "platform.getStakingAssetID":
|
|
// Quasar staking asset (arbitrary deterministic ID for tests)
|
|
resp.Result = map[string]interface{}{
|
|
"assetID": "2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM",
|
|
}
|
|
case "platform.getFeeConfig":
|
|
// LP-103 dynamic fee config — minimal values are fine for FetchState.
|
|
// gas.Gas / gas.Price are uint64 so values are emitted as numbers.
|
|
resp.Result = map[string]interface{}{
|
|
"weights": []uint64{1, 1, 1, 1},
|
|
"maxCapacity": uint64(1000000),
|
|
"maxPerSecond": uint64(100000),
|
|
"targetPerSecond": uint64(10000),
|
|
"minPrice": uint64(1),
|
|
"excessConversionConstant": uint64(1),
|
|
}
|
|
case "platform.getFeeState":
|
|
resp.Result = map[string]interface{}{
|
|
"capacity": uint64(1000000),
|
|
"excess": uint64(0),
|
|
"price": uint64(1),
|
|
"timestamp": "2026-06-06T00:00:00Z",
|
|
}
|
|
default:
|
|
resp.Error = &rpcErr{Code: -32601, Message: fmt.Sprintf("method %s not found", req.Method)}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// TestFetchState_XChainNotRegistered verifies the canonical fix: when
|
|
// the node's info service reports no X-Chain alias (the Quasar-era
|
|
// mainnet P+C topology), FetchState succeeds with an X-Chain context
|
|
// whose BlockchainID is the ids.Empty sentinel. Previously this errored
|
|
// out with `failed to decode client response: there is no ID with alias: X`.
|
|
func TestFetchState_XChainNotRegistered(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
fake := &fakeInfoServer{xChainServed: false}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/v1/info", fake.handleInfo)
|
|
mux.HandleFunc("/v1/chain/P", fake.handlePChain)
|
|
mux.HandleFunc("/v1/P", fake.handlePChain)
|
|
// Reject any non-P-Chain call: the SDK fallback MUST NOT touch
|
|
// platform.getUTXOs against the X-Chain alias when XCTX.BlockchainID
|
|
// is the sentinel. If anything else gets called the test fails fast.
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.URL.Path, "/v1/chain/X") {
|
|
t.Errorf("SDK called X-Chain endpoint %s in P-only mode", r.URL.Path)
|
|
}
|
|
http.NotFound(w, r)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
addrs := set.Set[ids.ShortID]{}
|
|
addrs.Add(ids.ShortEmpty)
|
|
|
|
state, err := FetchState(context.Background(), srv.URL, addrs)
|
|
require.NoError(err, "FetchState must succeed in P-only mode")
|
|
require.NotNil(state)
|
|
require.Nil(state.XCTX, "XCTX must be nil when X-Chain is not registered (no sentinel kludge)")
|
|
require.Nil(state.XClient, "XClient must be nil when X-Chain is not registered")
|
|
require.NotNil(state.PCTX)
|
|
require.NotNil(state.PClient)
|
|
}
|
|
|
|
// handleXChain answers the single X-Chain read the wallet makes: xvm.getUTXOs.
|
|
// The X wallet is built over this set, so a state fetch that never calls it
|
|
// would leave the wallet unable to spend.
|
|
func (f *fakeInfoServer) handleXChain(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
defer r.Body.Close()
|
|
var req struct {
|
|
ID int `json:"id"`
|
|
Method string `json:"method"`
|
|
}
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
resp := map[string]interface{}{
|
|
"jsonrpc": "2.0",
|
|
"id": req.ID,
|
|
"result": map[string]interface{}{
|
|
"numFetched": "0",
|
|
"utxos": []string{},
|
|
"endIndex": map[string]string{
|
|
"address": "X-lux1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzu5uf3",
|
|
"utxo": "11111111111111111111111111111111LpoYY",
|
|
},
|
|
"encoding": "hex",
|
|
},
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// TestFetchState_XChainRegistered verifies the happy path is unchanged:
|
|
// when the X alias resolves, XCTX.BlockchainID is non-empty.
|
|
func TestFetchState_XChainRegistered(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
fake := &fakeInfoServer{xChainServed: true}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/v1/info", fake.handleInfo)
|
|
mux.HandleFunc("/v1/chain/P", fake.handlePChain)
|
|
mux.HandleFunc("/v1/P", fake.handlePChain)
|
|
mux.HandleFunc("/v1/chain/X", fake.handleXChain)
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
addrs := set.Set[ids.ShortID]{}
|
|
addrs.Add(ids.ShortEmpty)
|
|
|
|
state, err := FetchState(context.Background(), srv.URL, addrs)
|
|
require.NoError(err)
|
|
require.NotEqual(ids.Empty, state.XCTX.BlockchainID, "BlockchainID must be set when X-Chain is registered")
|
|
require.NotNil(state.XClient, "XClient must be set when X-Chain is registered")
|
|
}
|