Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions cmd/vpn-cli/common_cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package main

import (
"context"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"

"github.com/blinklabs-io/vpn-indexer/internal/config"
"github.com/blinklabs-io/vpn-indexer/internal/database"
"github.com/blinklabs-io/vpn-indexer/internal/txbuilder"
)

// It makes sure --format is hex or cbor
func validateFormat(format string) error {
switch strings.ToLower(format) {
case "hex", "cbor":
return nil
default:
return fmt.Errorf("invalid --format %q (must be hex|cbor)", format)
}
}

// loads global config and applies CLI overrides.
func initConfig(kupoURL, ogmiosURL string) (*config.Config, error) {
if _, err := config.Load(""); err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
cfg := config.GetConfig()
if kupoURL != "" {
cfg.TxBuilder.KupoUrl = kupoURL
}
if ogmiosURL != "" {
cfg.TxBuilder.OgmiosUrl = ogmiosURL
// reset Apollo/Ogmios-derived cache in txbuilder path
txbuilder.ResetCachedSystemStart()
}
return cfg, nil
}

// It makes sure both Kupo and Ogmios URLs exist
func requireEndpoints(cfg *config.Config) error {
if strings.TrimSpace(cfg.TxBuilder.KupoUrl) == "" {
return errors.New("kupo url is required (set --kupo-url)")
}
if strings.TrimSpace(cfg.TxBuilder.OgmiosUrl) == "" {
return errors.New("ogmios url is required (set --ogmios-url)")
}
return nil
}

func loadRefData(ctx context.Context) (database.Reference, error) {
return loadReferenceFromKugoClient(ctx)
}

// Ir writes CBOR/HEX based on format
func writeOutHelper(format, outPath string, cborBytes []byte) error {
switch strings.ToLower(format) {
case "hex":
s := strings.ToUpper(hex.EncodeToString(cborBytes)) + "\n"
return writeOut(outPath, []byte(s))
case "cbor":
return writeOut(outPath, cborBytes)
default:
return fmt.Errorf("unsupported format: %s", format)
}
}

func writeOut(path string, data []byte) error {
if path == "" {
_, err := os.Stdout.Write(data)
return err
}
return os.WriteFile(path, data, 0o644)
}
12 changes: 12 additions & 0 deletions cmd/vpn-cli/datum_ref.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,15 @@ func toInt(v any) (int, bool) {
return 0, false
}
}

// Convert string to bytes as well as bytes to string
func toString(v any) (string, bool) {
switch x := v.(type) {
case string:
return x, true
case []byte:
return string(x), true
default:
return "", false
}
}
105 changes: 105 additions & 0 deletions cmd/vpn-cli/kupo_ref.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import (
"errors"
"fmt"
"strings"
"time"

serAddress "github.com/Salvionied/apollo/serialization/Address"
"github.com/SundaeSwap-finance/kugo"
"github.com/blinklabs-io/gouroboros/cbor"
"github.com/blinklabs-io/vpn-indexer/internal/config"
"github.com/blinklabs-io/vpn-indexer/internal/database"
)
Expand Down Expand Up @@ -116,3 +119,105 @@ func parseHex(s string) string {
s = strings.TrimPrefix(s, "0X")
return s
}

// It finds the client UTXO based on script address & fetches its datum & decodes it.
func findClientOnChain(ctx context.Context, clientIdHex string) (database.Client, error) {
cfg := config.GetConfig()
if strings.TrimSpace(cfg.Indexer.ScriptAddress) == "" {
return database.Client{}, errors.New("script address not configured")
}

// script address contains policy id & asset name
scriptAddrBytes, err := serAddress.DecodeAddress(cfg.Indexer.ScriptAddress)
if err != nil {
return database.Client{}, fmt.Errorf("failed in decoding script address: %w", err)
}
policyHex := strings.ToLower(hex.EncodeToString(scriptAddrBytes.PaymentPart))
targetAsset := strings.ToLower(parseHex(clientIdHex))

k, err := getKupoClient()
if err != nil {
return database.Client{}, err
}

// Search for all UTXOs that contains the below asset pattern
assetPattern := fmt.Sprintf("%s.%s", policyHex, targetAsset)
matches, err := k.Matches(ctx, kugo.Pattern(assetPattern))
if err != nil {
return database.Client{}, fmt.Errorf("kupo matches(script address): %w", err)
}

if len(matches) == 0 {
return database.Client{}, fmt.Errorf("client not found for clientId=%s", clientIdHex)
}

// From all UTXOs, pick the one at script address
var picked *kugo.Match
for i := range matches {
m := matches[i]
if strings.EqualFold(strings.TrimSpace(m.Address), strings.TrimSpace(cfg.Indexer.ScriptAddress)) {
picked = &m
break
}
}
if picked == nil {
picked = &matches[0]
}
if strings.TrimSpace(picked.DatumHash) == "" {
return database.Client{}, errors.New("selected UTXO has no datum hash")
}

// Fetch datum (client information) using datum hash
datumHex, err := k.Datum(ctx, parseHex(picked.DatumHash))
if err != nil {
return database.Client{}, fmt.Errorf("kupo datum: %w", err)
}
datumBytes, err := hex.DecodeString(parseHex(datumHex))
if err != nil {
return database.Client{}, fmt.Errorf("datum hex decode: %w", err)
}

// Decode CBOR into any first
var v any
if _, err := cbor.Decode(datumBytes, &v); err != nil {
return database.Client{}, fmt.Errorf("datum cbor decode: %w", err)
}

// Remove CBOR wrappers (Tag/Constructors)
fields, ok := toSlice(unwrapAll(v))
if !ok || len(fields) != 3 {
return database.Client{}, errors.New("unexpected client datum shape")
}
credentialRaw := unwrapAll(fields[0])
credential, ok := credentialRaw.([]byte)
if !ok || len(credential) == 0 {
return database.Client{}, errors.New("invalid credential in client datum")
}
region, ok := toString(unwrapAll(fields[1]))
if !ok || region == "" {
return database.Client{}, errors.New("invalid region in client datum")
}
expirationMs, ok := toInt(unwrapAll(fields[2]))
if !ok {
return database.Client{}, errors.New("invalid expiration in client datum")
}
if credential == nil || region == "" {
return database.Client{}, errors.New("invalid fields in client datum")
}

// Extract transaction hash
txid, err := hex.DecodeString(parseHex(picked.TransactionID))
if err != nil {
return database.Client{}, fmt.Errorf("txid decode failure: %w", err)
}
assetNameBytes, _ := hex.DecodeString(targetAsset)

return database.Client{
AssetName: assetNameBytes,
Expiration: time.UnixMilli(int64(expirationMs)),
Credential: credential,
Region: region,
TxHash: txid,
TxOutputIndex: uint(picked.OutputIndex),
}, nil
}
100 changes: 100 additions & 0 deletions cmd/vpn-cli/renew.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package main

import (
"errors"
"fmt"
"strings"

"github.com/blinklabs-io/vpn-indexer/internal/txbuilder"
"github.com/spf13/cobra"
)

var (
flagRenewPayment string
flagRenewOwner string
flagRenewClientID string
flagRenewPrice int
flagRenewDuration int

flagRenewOgmiosURL string
flagRenewKupoURL string
)

func init() {
cmd := &cobra.Command{
Use: "renew",
Short: "Build an unsigned renewal transaction",
RunE: runRenew,
}

cmd.Flags().StringVar(&flagRenewPayment, "payment", "", "client payment bech32 address (required)")
cmd.Flags().StringVar(&flagRenewOwner, "owner", "", "owner bech32 address")
cmd.Flags().StringVar(&flagRenewClientID, "client-id", "", "existing client ID (required)")
cmd.Flags().IntVar(&flagRenewPrice, "price", 0, "plan price in lovelace")
cmd.Flags().IntVar(&flagRenewDuration, "duration", 0, "plan duration in milliseconds")

// Load from on chain using Kupo/Ogmios
cmd.Flags().StringVar(&flagRenewOgmiosURL, "ogmios-url", "", "Ogmios endpoint (optional)")
cmd.Flags().StringVar(&flagRenewKupoURL, "kupo-url", "", "Kupo endpoint (used if --refdata not provided)")

_ = cmd.MarkFlagRequired("payment")
_ = cmd.MarkFlagRequired("price")
_ = cmd.MarkFlagRequired("duration")
_ = cmd.MarkFlagRequired("client-id")

rootCmd.AddCommand(cmd)
}

func runRenew(cmd *cobra.Command, _ []string) error {
if err := validateFormat(format); err != nil {
return err
}
if flagRenewPayment == "" {
return errors.New("--payment is required")
}
if flagRenewPrice <= 0 {
return errors.New("--price must be > 0")
}
if flagRenewDuration <= 0 {
return errors.New("--duration must be > 0")
}
if strings.TrimSpace(flagRenewClientID) == "" {
return errors.New("--client-id is required")
}

cfg, err := initConfig(flagRenewKupoURL, flagRenewOgmiosURL)
if err != nil {
return err
}
if err := requireEndpoints(cfg); err != nil {
return err
}
ref, err := loadRefData(cmd.Context())
if err != nil {
return fmt.Errorf("load reference (kupo): %w", err)
}

// find the client
client, err := findClientOnChain(cmd.Context(), flagRenewClientID)
if err != nil {
return fmt.Errorf("find client (kupo): %w", err)
}

// Build unsigned tx using txbuilder.
cborBytes, err := txbuilder.BuildRenewTransferTx(
txbuilder.RenewDeps{
Ref: &ref,
Client: &client,
},
flagRenewPayment,
flagRenewOwner,
flagRenewClientID,
flagRenewPrice,
flagRenewDuration,
)
if err != nil {
return err
}

return writeOutHelper(format, outPath, cborBytes)
}
53 changes: 10 additions & 43 deletions cmd/vpn-cli/signup.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
package main

import (
"encoding/hex"
"errors"
"fmt"
"os"
"strings"

"github.com/blinklabs-io/vpn-indexer/internal/config"
"github.com/blinklabs-io/vpn-indexer/internal/txbuilder"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -65,11 +62,10 @@ func main() {
}

func runSignup(cmd *cobra.Command, _ []string) error {
switch strings.ToLower(format) {
case "hex", "cbor":
default:
return fmt.Errorf("invalid --format %q (must be hex|cbor)", format)
if err := validateFormat(format); err != nil {
return err
}

if flagPaymentAddr == "" {
return errors.New("--client is required")
}
Expand All @@ -83,28 +79,14 @@ func runSignup(cmd *cobra.Command, _ []string) error {
return errors.New("--region is required")
}

// Load global config (no file needed)
if _, err := config.Load(""); err != nil {
return fmt.Errorf("load config: %w", err)
}
cfg := config.GetConfig()
if flagKupoURL != "" {
cfg.TxBuilder.KupoUrl = flagKupoURL
}
if flagOgmiosURL != "" {
cfg.TxBuilder.OgmiosUrl = flagOgmiosURL
txbuilder.ResetCachedSystemStart()
}

if strings.TrimSpace(cfg.TxBuilder.KupoUrl) == "" {
return fmt.Errorf("kupo url is required (set --kupo-url)")
cfg, err := initConfig(flagKupoURL, flagOgmiosURL)
if err != nil {
return err
}
if strings.TrimSpace(cfg.TxBuilder.OgmiosUrl) == "" {
return fmt.Errorf("ogmios url is required (set --ogmios-url)")
if err := requireEndpoints(cfg); err != nil {
return err
}

// Load reference data from Kupo
ref, err := loadReferenceFromKugoClient(cmd.Context())
ref, err := loadRefData(cmd.Context())
if err != nil {
return fmt.Errorf("load reference (kupo): %w", err)
}
Expand All @@ -122,20 +104,5 @@ func runSignup(cmd *cobra.Command, _ []string) error {
return err
}

switch strings.ToLower(format) {
case "hex":
s := strings.ToUpper(hex.EncodeToString(cborBytes)) + "\n"
return writeOut(outPath, []byte(s))
case "cbor":
return writeOut(outPath, cborBytes)
}
return nil
}

func writeOut(path string, data []byte) error {
if path == "" {
_, err := os.Stdout.Write(data)
return err
}
return os.WriteFile(path, data, 0o644)
return writeOutHelper(format, outPath, cborBytes)
}
Loading
Loading