mirror of
https://github.com/luxfi/cli.git
synced 2026-10-30 17:01:20 +00:00
The SDK's primary wallet no longer builds a C-Chain wallet, and every caller here was carrying scaffolding for the one it used to build: an emptyEVMKeychain that answers nothing, passed as the EVMKeychain of a wallet that never consults it, and in one case a nil. rpc transfer went further and reached around the wallet, building C-Chain atomic import and export transactions by hand against lux.issueTx and polling lux.getAtomicTxStatus. Value on the C-Chain moves by an ordinary EVM transaction against /v1/bc/C/rpc; an atomic export from an EVM chain is a different act with a different settlement path, and this command was not the place for it. transfer now moves value between P and X, names an unsupported chain in its error, and reports both the export and the import transaction rather than one. Co-authored-by: Hanzo Dev <[email protected]>
243 lines
7.0 KiB
Go
243 lines
7.0 KiB
Go
// Copyright (C) 2025, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package rpccmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/big"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/luxfi/address"
|
|
"github.com/luxfi/cli/pkg/application"
|
|
"github.com/luxfi/cli/pkg/key"
|
|
"github.com/luxfi/cli/pkg/ux"
|
|
"github.com/luxfi/constants"
|
|
"github.com/luxfi/ids"
|
|
sdkinfo "github.com/luxfi/sdk/info"
|
|
"github.com/luxfi/sdk/wallet/primary"
|
|
"github.com/luxfi/utxo"
|
|
"github.com/luxfi/utxo/secp256k1fx"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type transferFlags struct {
|
|
rpcURL string
|
|
from string
|
|
fromChain string
|
|
toChain string
|
|
to string
|
|
amount float64
|
|
}
|
|
|
|
func newTransferCmd(app *application.Lux) *cobra.Command {
|
|
flags := &transferFlags{}
|
|
cmd := &cobra.Command{
|
|
Use: "transfer",
|
|
Short: "Transfer LUX between the P and X chains",
|
|
Long: `Transfer LUX between the P and X chains by atomic export/import.
|
|
|
|
The C-Chain is an EVM: move value on it with an ordinary EVM transaction
|
|
against /v1/bc/C/rpc, not with this command.
|
|
|
|
Example:
|
|
lux rpc transfer --from-chain P --to-chain X --to X-lux1... --amount 10
|
|
`,
|
|
RunE: func(_ *cobra.Command, _ []string) error {
|
|
return runTransfer(app, flags)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringVar(&flags.rpcURL, "rpc-url", "", "Base RPC URL (default: RPC_URL or running network endpoint)")
|
|
cmd.Flags().StringVar(&flags.from, "from", "", "Key name to use (default: MNEMONIC account 0)")
|
|
cmd.Flags().StringVar(&flags.fromChain, "from-chain", "P", "Source chain: P or X")
|
|
cmd.Flags().StringVar(&flags.toChain, "to-chain", "X", "Destination chain: P or X")
|
|
cmd.Flags().StringVar(&flags.to, "to", "", "Destination bech32 address")
|
|
cmd.Flags().Float64Var(&flags.amount, "amount", 0, "Amount to transfer in LUX")
|
|
|
|
_ = cmd.MarkFlagRequired("amount")
|
|
_ = cmd.MarkFlagRequired("to")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func runTransfer(app *application.Lux, flags *transferFlags) error {
|
|
source, err := chainIDFromAlias(flags.fromChain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
destination, err := chainIDFromAlias(flags.toChain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if source == destination {
|
|
return fmt.Errorf("from-chain and to-chain must differ")
|
|
}
|
|
|
|
amount, err := luxToMicroLux(flags.amount)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
owner, err := outputOwnerFromBech32Address(flags.to)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
baseURL, err := resolveRPCBaseURL(app, flags.rpcURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
networkID, err := resolveNetworkID(ctx, baseURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
softKey, err := loadSoftKeyForTransfer(networkID, flags.from)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wallet, err := makePrimaryWallet(ctx, baseURL, softKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var exportID, importID ids.ID
|
|
if source == constants.PlatformChainID {
|
|
exported := []*utxo.TransferableOutput{transferOut(wallet.P().Builder().Context().UTXOAssetID, amount, owner)}
|
|
exportTx, err := wallet.P().IssueExportTx(destination, exported)
|
|
if err != nil {
|
|
return fmt.Errorf("P export failed: %w", err)
|
|
}
|
|
importTx, err := wallet.X().IssueImportTx(source, owner)
|
|
if err != nil {
|
|
return fmt.Errorf("X import failed: %w", err)
|
|
}
|
|
exportID, importID = exportTx.ID(), importTx.ID()
|
|
} else {
|
|
exported := []*utxo.TransferableOutput{transferOut(wallet.X().Builder().Context().UTXOAssetID, amount, owner)}
|
|
exportTx, err := wallet.X().IssueExportTx(destination, exported)
|
|
if err != nil {
|
|
return fmt.Errorf("X export failed: %w", err)
|
|
}
|
|
importTx, err := wallet.P().IssueImportTx(source, owner)
|
|
if err != nil {
|
|
return fmt.Errorf("P import failed: %w", err)
|
|
}
|
|
exportID, importID = exportTx.ID(), importTx.ID()
|
|
}
|
|
|
|
ux.Logger.PrintToUser("%s -> %s transfer submitted", flags.fromChain, flags.toChain)
|
|
ux.Logger.PrintToUser(" Export TxID: %s", exportID)
|
|
ux.Logger.PrintToUser(" Import TxID: %s", importID)
|
|
return nil
|
|
}
|
|
|
|
func transferOut(assetID ids.ID, amount uint64, owner *secp256k1fx.OutputOwners) *utxo.TransferableOutput {
|
|
return &utxo.TransferableOutput{
|
|
Asset: utxo.Asset{ID: assetID},
|
|
Out: &secp256k1fx.TransferOutput{
|
|
Amt: amount,
|
|
OutputOwners: *owner,
|
|
},
|
|
}
|
|
}
|
|
|
|
func resolveRPCBaseURL(app *application.Lux, override string) (string, error) {
|
|
if override != "" {
|
|
return strings.TrimSuffix(override, "/"), nil
|
|
}
|
|
if env := os.Getenv("RPC_URL"); env != "" {
|
|
return strings.TrimSuffix(env, "/"), nil
|
|
}
|
|
if app != nil {
|
|
if endpoint := app.GetRunningNetworkEndpoint(); endpoint != "" {
|
|
return strings.TrimSuffix(endpoint, "/"), nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("rpc base URL not set (use --rpc-url or RPC_URL)")
|
|
}
|
|
|
|
func resolveNetworkID(ctx context.Context, baseURL string) (uint32, error) {
|
|
networkID, err := sdkinfo.NewClient(baseURL).GetNetworkID(ctx)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get network ID: %w", err)
|
|
}
|
|
return networkID, nil
|
|
}
|
|
|
|
func loadSoftKeyForTransfer(networkID uint32, from string) (*key.SoftKey, error) {
|
|
if from != "" {
|
|
keySet, err := key.LoadKeySet(from)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load key '%s': %w", from, err)
|
|
}
|
|
if len(keySet.ECPrivateKey) == 0 {
|
|
return nil, fmt.Errorf("key '%s' has no EC private key", from)
|
|
}
|
|
return key.NewSoftFromBytes(networkID, keySet.ECPrivateKey)
|
|
}
|
|
mnemonic := key.GetMnemonicFromEnv()
|
|
if mnemonic == "" {
|
|
return nil, fmt.Errorf("no key specified and MNEMONIC not set")
|
|
}
|
|
return key.NewSoftFromMnemonic(networkID, mnemonic)
|
|
}
|
|
|
|
func makePrimaryWallet(ctx context.Context, baseURL string, sk *key.SoftKey) (primary.Wallet, error) {
|
|
return primary.MakeWallet(ctx, &primary.WalletConfig{
|
|
URI: baseURL,
|
|
LUXKeychain: primary.NewKeychainAdapter(secp256k1fx.NewKeychain(sk.Key())),
|
|
})
|
|
}
|
|
|
|
func chainIDFromAlias(alias string) (ids.ID, error) {
|
|
switch strings.ToUpper(alias) {
|
|
case "P":
|
|
return constants.PlatformChainID, nil
|
|
case "X":
|
|
return constants.XChainID, nil
|
|
default:
|
|
return ids.Empty, fmt.Errorf("unsupported chain %q: transfer moves value between P and X", alias)
|
|
}
|
|
}
|
|
|
|
func outputOwnerFromBech32Address(addr string) (*secp256k1fx.OutputOwners, error) {
|
|
bech32Addr := addr
|
|
if parts := strings.SplitN(addr, "-", 2); len(parts) == 2 {
|
|
bech32Addr = parts[1]
|
|
}
|
|
_, addrBytes, err := address.ParseBech32(bech32Addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid bech32 address: %w", err)
|
|
}
|
|
shortID, err := ids.ToShortID(addrBytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid bech32 address bytes: %w", err)
|
|
}
|
|
return &secp256k1fx.OutputOwners{
|
|
Threshold: 1,
|
|
Addrs: []ids.ShortID{shortID},
|
|
}, nil
|
|
}
|
|
|
|
// luxToMicroLux converts whole LUX to the P/X-Chain base unit (microLUX, 6
|
|
// decimals — NOT the legacy 9-decimal nanoLUX). 1 LUX = 1e6 microLUX.
|
|
func luxToMicroLux(amount float64) (uint64, error) {
|
|
if amount <= 0 {
|
|
return 0, fmt.Errorf("amount must be positive")
|
|
}
|
|
value := new(big.Float).Mul(new(big.Float).SetFloat64(amount), big.NewFloat(1e6))
|
|
microlux := new(big.Int)
|
|
value.Int(microlux)
|
|
if !microlux.IsUint64() {
|
|
return 0, fmt.Errorf("amount too large")
|
|
}
|
|
return microlux.Uint64(), nil
|
|
}
|