From ce5be9c6642333cc6d84076a25229673c26ac6cd Mon Sep 17 00:00:00 2001 From: Jefferson Sankara Date: Wed, 16 Jul 2025 16:55:27 -0700 Subject: [PATCH 1/2] chore: update wallet and handler logic for Lotus balance troubleshooting --- cmd/testutil.go | 190 +++++++++++------------- cmd/wallet/balance.go | 6 +- cmd/wallet/list.go | 73 ++++++--- cmd/wallet_test.go | 174 +++++++++++++++++++--- docs/en/cli-reference/wallet/README.md | 4 +- docs/en/cli-reference/wallet/balance.md | 10 +- docs/jp/cli-reference/wallet/list.md | 17 ++- handler/wallet/interface.go | 49 ++++++ handler/wallet/list.go | 49 +++++- handler/wallet/list_test.go | 117 ++++++++++++++- 10 files changed, 525 insertions(+), 164 deletions(-) diff --git a/cmd/testutil.go b/cmd/testutil.go index 7642b113..8975f0f7 100644 --- a/cmd/testutil.go +++ b/cmd/testutil.go @@ -1,4 +1,3 @@ -//nolint:forcetypeassert package cmd import ( @@ -10,25 +9,69 @@ import ( "os" "path/filepath" "regexp" - "strconv" + "slices" "strings" "sync" "testing" "time" - "slices" - "github.com/cockroachdb/errors" + "github.com/data-preservation-programs/singularity/handler/wallet" + "github.com/data-preservation-programs/singularity/model" "github.com/data-preservation-programs/singularity/pack" "github.com/fatih/color" commp "github.com/filecoin-project/go-fil-commp-hashhash" "github.com/mattn/go-shellwords" "github.com/parnurzeal/gorequest" "github.com/rjNemo/underscore" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" ) +// CompareDirectories compares the contents of two directories recursively. +func CompareDirectories(t *testing.T, dir1, dir2 string) { + t.Helper() + filesInDir2 := make(map[string]struct{}) + + err := filepath.Walk(dir1, func(path1 string, info1 os.FileInfo, err error) error { + require.NoError(t, err) + relPath := strings.TrimPrefix(path1, dir1) + path2 := filepath.Join(dir2, relPath) + info2, err := os.Stat(path2) + if os.IsNotExist(err) { + require.Failf(t, "Missing file or directory in dir2", "File: %s", relPath) + return nil + } + require.NoError(t, err) + if !info1.IsDir() { + filesInDir2[relPath] = struct{}{} + } + if info1.IsDir() && info2.IsDir() { + return nil + } + require.Equal(t, info1.Size(), info2.Size(), "Size mismatch for %s", relPath) + content1, err := os.ReadFile(filepath.Clean(path1)) + require.NoError(t, err) + content2, err := os.ReadFile(filepath.Clean(path2)) + require.NoError(t, err) + require.True(t, bytes.Equal(content1, content2), "Content mismatch for %s", relPath) + return nil + }) + require.NoError(t, err) + + err = filepath.Walk(dir2, func(path2 string, info2 os.FileInfo, err error) error { + require.NoError(t, err) + relPath := strings.TrimPrefix(path2, dir2) + if _, ok := filesInDir2[relPath]; ok || info2.IsDir() { + return nil + } + require.Failf(t, "Extra file or directory in dir2", "File: %s", relPath) + return nil + }) + require.NoError(t, err) +} + type RunnerMode string const ( @@ -42,7 +85,33 @@ type Runner struct { mode RunnerMode } -var colorMutex = sync.Mutex{} +var ( + removeANSI = regexp.MustCompile(`\x1B\[[0-?]*[ -/]*[@-~]`) + timeRegex = regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`) + colorMutex = sync.Mutex{} +) + +// Save writes the captured output to testdata files for inspection. +func (r *Runner) Save(t *testing.T, tempDirs ...string) { + t.Helper() + ansi := r.sb.String() + if ansi == "" { + return + } + for i, tempDir := range tempDirs { + ansi = strings.ReplaceAll(ansi, tempDir, "/tempDir/"+fmt.Sprint(i)) + } + ansi = timeRegex.ReplaceAllString(ansi, "2023-04-05 06:07:08") + ansiPath := "testdata/" + t.Name() + ".ansi" + err := os.MkdirAll("testdata", 0700) + require.NoError(t, err) + err = os.WriteFile(ansiPath, []byte(ansi), 0600) + require.NoError(t, err) + plain := removeANSI.ReplaceAllString(ansi, "") + plainPath := "testdata/" + t.Name() + ".txt" + err = os.WriteFile(plainPath, []byte(plain), 0600) + require.NoError(t, err) +} // NewRunner creates a new Runner to capture CLI args // @@ -55,14 +124,21 @@ func NewRunner() *Runner { if color.NoColor { color.NoColor = false } + // Always swap in a mock wallet handler for all CLI tests + // This ensures all wallet DB operations use the mock, not the real DB + mockHandler := new(wallet.MockWallet) + // Set up default no-op mocks for all handler methods to avoid nil panics + mockHandler.On("CreateHandler", mock.Anything, mock.Anything, mock.Anything).Return(&model.Wallet{}, nil) + mockHandler.On("ImportHandler", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&model.Wallet{}, nil) + mockHandler.On("InitHandler", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&model.Wallet{}, nil) + mockHandler.On("ListHandler", mock.Anything, mock.Anything).Return([]model.Wallet{}, nil) + mockHandler.On("RemoveHandler", mock.Anything, mock.Anything, mock.Anything).Return(nil) + mockHandler.On("UpdateHandler", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&model.Wallet{}, nil) + wallet.Default = mockHandler return &Runner{} } -func (r *Runner) WithMode(mode RunnerMode) *Runner { - r.mode = mode - return r -} - +// Run executes the CLI command with the given arguments and captures output. func (r *Runner) Run(ctx context.Context, args string) (string, string, error) { if strings.HasPrefix(args, "singularity ") { switch r.mode { @@ -82,35 +158,6 @@ func (r *Runner) Run(ctx context.Context, args string) (string, string, error) { return out, stderr, err } -var removeANSI = regexp.MustCompile(`\x1B\[[0-?]*[ -/]*[@-~]`) -var timeRegex = regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`) - -func (r *Runner) Save(t *testing.T, tempDirs ...string) { - t.Helper() - t.Helper() - ansi := r.sb.String() - if ansi == "" { - return - } - - for i, tempDir := range tempDirs { - ansi = strings.ReplaceAll(ansi, tempDir, "/tempDir/"+strconv.Itoa(i)) - } - - ansi = timeRegex.ReplaceAllString(ansi, "2023-04-05 06:07:08") - - ansiPath := filepath.Join("testdata", t.Name()+".ansi") - err := os.MkdirAll(filepath.Dir(ansiPath), 0700) - require.NoError(t, err) - err = os.WriteFile(ansiPath, []byte(ansi), 0600) - require.NoError(t, err) - - plain := removeANSI.ReplaceAllString(ansi, "") - plainPath := filepath.Join("testdata", t.Name()+".txt") - err = os.WriteFile(plainPath, []byte(plain), 0600) - require.NoError(t, err) -} - var pieceCIDRegex = regexp.MustCompile("baga6ea[0-9a-z]+") func GetAllPieceCIDs(content string) []string { @@ -261,71 +308,6 @@ func Download(ctx context.Context, url string, nThreads int) ([]byte, error) { return result.Bytes(), nil } -func CompareDirectories(t *testing.T, dir1, dir2 string) { - t.Helper() - filesInDir2 := make(map[string]struct{}) - - err := filepath.Walk(dir1, func(path1 string, info1 os.FileInfo, err error) error { - // Propagate any error - require.NoError(t, err) - - // Construct the path to the corresponding file or directory in dir2 - relPath := strings.TrimPrefix(path1, dir1) - path2 := filepath.Join(dir2, relPath) - - // Get info about the file or directory in dir2 - info2, err := os.Stat(path2) - if os.IsNotExist(err) { - require.Failf(t, "Missing file or directory in dir2", "File: %s", relPath) - return nil - } - require.NoError(t, err) - - if !info1.IsDir() { - filesInDir2[relPath] = struct{}{} - } - - // If both are directories, no need to compare content - if info1.IsDir() && info2.IsDir() { - return nil - } - - // Compare file sizes - require.Equal(t, info1.Size(), info2.Size(), "Size mismatch for %s", relPath) - - // Compare file content - content1, err := os.ReadFile(filepath.Clean(path1)) - require.NoError(t, err) - - content2, err := os.ReadFile(filepath.Clean(path2)) - require.NoError(t, err) - - require.True(t, bytes.Equal(content1, content2), "Content mismatch for %s", relPath) - - return nil - }) - - require.NoError(t, err) - - err = filepath.Walk(dir2, func(path2 string, info2 os.FileInfo, err error) error { - // Propagate any error - require.NoError(t, err) - - relPath := strings.TrimPrefix(path2, dir2) - - // If we've already checked this file (because it exists in dir1), then skip it - if _, ok := filesInDir2[relPath]; ok || info2.IsDir() { - return nil - } - - // If we get here, it means this file/dir exists in dir2 but not in dir1 - require.Failf(t, "Extra file or directory in dir2", "File: %s", relPath) - return nil - }) - - require.NoError(t, err) -} - func runWithCapture(ctx context.Context, args string) (string, string, error) { // Create a clone of the app so that we can runWithCapture from different tests concurrently app := *App diff --git a/cmd/wallet/balance.go b/cmd/wallet/balance.go index 3e6790ab..1bac13fb 100644 --- a/cmd/wallet/balance.go +++ b/cmd/wallet/balance.go @@ -13,12 +13,12 @@ var BalanceCmd = &cli.Command{ Name: "balance", Usage: "Get wallet balance information", ArgsUsage: "", - Description: `Get FIL balance and FIL+ datacap balance for a specific wallet address. +Description: `Get FIL balance and FIL+ datacap balance for a specific wallet address. This command queries the Lotus network to retrieve current balance information. Examples: - singularity wallet balance f12syf7zd3lfsv43aj2kb454ymaqw7debhumjnbqa - singularity wallet balance --json f1abc123...def456 + singularity wallet balance f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz + singularity wallet balance --json f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz The command returns: - FIL balance in human-readable format (e.g., "1.000000 FIL") diff --git a/cmd/wallet/list.go b/cmd/wallet/list.go index d696d1d2..b2549c93 100644 --- a/cmd/wallet/list.go +++ b/cmd/wallet/list.go @@ -1,28 +1,61 @@ package wallet import ( - "github.com/cockroachdb/errors" - "github.com/data-preservation-programs/singularity/cmd/cliutil" - "github.com/data-preservation-programs/singularity/database" - "github.com/data-preservation-programs/singularity/handler/wallet" - "github.com/urfave/cli/v2" + "github.com/cockroachdb/errors" + "github.com/data-preservation-programs/singularity/cmd/cliutil" + "github.com/data-preservation-programs/singularity/database" + "github.com/data-preservation-programs/singularity/handler/wallet" + "github.com/data-preservation-programs/singularity/util" + "github.com/urfave/cli/v2" ) var ListCmd = &cli.Command{ - Name: "list", - Usage: "List all imported wallets", - Action: func(c *cli.Context) error { - db, closer, err := database.OpenFromCLI(c) - if err != nil { - return errors.WithStack(err) - } - defer func() { _ = closer.Close() }() - wallets, err := wallet.Default.ListHandler(c.Context, db) - if err != nil { - return errors.WithStack(err) - } + Name: "list", + Usage: "List all imported wallets", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "with-balance", + Usage: "Fetch and display live wallet balances from Lotus", + Value: false, + }, + &cli.StringFlag{ + Name: "lotus-api", + Usage: "Lotus JSON-RPC API endpoint (required for --with-balance)", + EnvVars: []string{"LOTUS_API"}, + }, + &cli.StringFlag{ + Name: "lotus-token", + Usage: "Lotus API authorization token (required for --with-balance)", + EnvVars: []string{"LOTUS_TOKEN"}, + }, + }, + Action: func(c *cli.Context) error { + db, closer, err := database.OpenFromCLI(c) + if err != nil { + return errors.WithStack(err) + } + defer func() { _ = closer.Close() }() - cliutil.Print(c, wallets) - return nil - }, + if c.Bool("with-balance") { + lotusAPI := c.String("lotus-api") + lotusToken := c.String("lotus-token") + if lotusAPI == "" || lotusToken == "" { + return errors.New("Both --lotus-api and --lotus-token must be provided to fetch wallet balances.") + } + lotusClient := util.NewLotusClient(lotusAPI, lotusToken) + wallets, err := wallet.ListWithBalanceHandler(c.Context, db, lotusClient) + if err != nil { + return errors.WithStack(err) + } + cliutil.Print(c, wallets) + return nil + } + + wallets, err := wallet.Default.ListHandler(c.Context, db) + if err != nil { + return errors.WithStack(err) + } + cliutil.Print(c, wallets) + return nil + }, } diff --git a/cmd/wallet_test.go b/cmd/wallet_test.go index b46ec71c..cd6880e5 100644 --- a/cmd/wallet_test.go +++ b/cmd/wallet_test.go @@ -25,7 +25,11 @@ func swapWalletHandler(mockHandler wallet.Handler) func() { } func TestWalletCreate(t *testing.T) { - testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + testutil.One(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + var err error + err = model.GetMigrator(db).Migrate() + require.NoError(t, err) + runner := NewRunner() defer runner.Save(t) mockHandler := new(wallet.MockWallet) @@ -36,7 +40,7 @@ func TestWalletCreate(t *testing.T) { PrivateKey: "private", }, nil) - _, _, err := runner.Run(ctx, "singularity wallet create") + _, _, err = runner.Run(ctx, "singularity wallet create") require.NoError(t, err) _, _, err = runner.Run(ctx, "singularity --verbose wallet create") @@ -51,22 +55,30 @@ func TestWalletCreate(t *testing.T) { } func TestWalletCreate_BadType(t *testing.T) { - testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + testutil.One(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + var err error + err = model.GetMigrator(db).Migrate() + require.NoError(t, err) + runner := NewRunner() defer runner.Save(t) mockHandler := new(wallet.MockWallet) defer swapWalletHandler(mockHandler)() mockHandler.On("CreateHandler", mock.Anything, mock.Anything, mock.Anything).Return((*model.Wallet)(nil), errors.New("unsupported key type: not-a-real-type")) - _, _, err := runner.Run(ctx, "singularity wallet create not-a-real-type") + _, _, err = runner.Run(ctx, "singularity wallet create not-a-real-type") require.Error(t, err) }) } func TestWalletImport(t *testing.T) { - testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + testutil.One(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + var err error + err = model.GetMigrator(db).Migrate() + require.NoError(t, err) + tmp := t.TempDir() - err := os.WriteFile(filepath.Join(tmp, "private"), []byte("private"), 0644) + err = os.WriteFile(filepath.Join(tmp, "private"), []byte("private"), 0644) require.NoError(t, err) runner := NewRunner() @@ -96,7 +108,11 @@ func TestWalletImport(t *testing.T) { } func TestWalletInit(t *testing.T) { - testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + testutil.One(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + var err error + err = model.GetMigrator(db).Migrate() + require.NoError(t, err) + runner := NewRunner() defer runner.Save(t) mockHandler := new(wallet.MockWallet) @@ -107,13 +123,111 @@ func TestWalletInit(t *testing.T) { PrivateKey: "private", }, nil) - _, _, err := runner.Run(ctx, "singularity wallet init xxx") + _, _, err = runner.Run(ctx, "singularity wallet init xxx") require.NoError(t, err) }) } +func TestWalletListWithBalance(t *testing.T) { + t.Run("Lotus error", func(t *testing.T) { + testutil.One(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + var err error + err = model.GetMigrator(db).Migrate() + require.NoError(t, err) + + runner := NewRunner() + defer runner.Save(t) + mockHandler := new(wallet.MockWallet) + defer swapWalletHandler(mockHandler)() + mockHandler.On("ListWithBalanceHandler", mock.Anything, mock.Anything, mock.Anything).Return([]wallet.WalletWithBalance{ + { + Wallet: model.Wallet{ + ActorID: "id1", + Address: "f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz", + PrivateKey: "private1", + }, + Balance: "1 FIL", + BalanceAttoFIL: "1000000000000000000", + DataCap: "1 TiB", + DataCapBytes: 1099511627776, + }, + }, nil) + _, _, err = runner.Run(context.Background(), "singularity wallet list --with-balance --lotus-api http://mock --lotus-token mock") + require.NoError(t, err) + }) + }) + + t.Run("Partial Lotus error", func(t *testing.T) { + testutil.One(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + var err error + err = model.GetMigrator(db).Migrate() + require.NoError(t, err) + + runner := NewRunner() + defer runner.Save(t) + mockHandler := new(wallet.MockWallet) + defer swapWalletHandler(mockHandler)() + errMsg := "lotus error" + mockHandler.On("ListWithBalanceHandler", mock.Anything, mock.Anything, mock.Anything).Return([]wallet.WalletWithBalance{ + { + Wallet: model.Wallet{ + ActorID: "id1", + Address: "f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz", + PrivateKey: "private1", + }, + Error: &errMsg, + }, + { + Wallet: model.Wallet{ + ActorID: "id2", + Address: "f1fib3pv7jua2ockdugtz7viz3cyy6lkhh7rfx3sa", + PrivateKey: "private2", + }, + Balance: "1 FIL", + BalanceAttoFIL: "1000000000000000000", + DataCap: "1 TiB", + DataCapBytes: 1099511627776, + }}, nil) + _, _, err = runner.Run(context.Background(), "singularity wallet list --with-balance --lotus-api http://mock --lotus-token mock") + require.NoError(t, err) + }) + }) + + t.Run("Missing Lotus info", func(t *testing.T) { + testutil.One(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + var err error + err = model.GetMigrator(db).Migrate() + require.NoError(t, err) + + runner := NewRunner() + defer runner.Save(t) + mockHandler := new(wallet.MockWallet) + defer swapWalletHandler(mockHandler)() + mockHandler.On("ListWithBalanceHandler", mock.Anything, mock.Anything, mock.Anything).Return([]wallet.WalletWithBalance{ + { + Wallet: model.Wallet{ + ActorID: "id1", + Address: "f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz", + PrivateKey: "private1", + }, + Balance: "1 FIL", + BalanceAttoFIL: "1000000000000000000", + DataCap: "1 TiB", + DataCapBytes: 1099511627776, + }}, nil) + _, _, err = runner.Run(context.Background(), "singularity wallet list --with-balance") + require.Error(t, err) + require.Contains(t, err.Error(), "Both --lotus-api and --lotus-token must be provided") + }) + }) +} + func TestWalletList(t *testing.T) { - testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + testutil.One(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + var err error + err = model.GetMigrator(db).Migrate() + require.NoError(t, err) + runner := NewRunner() defer runner.Save(t) mockHandler := new(wallet.MockWallet) @@ -128,7 +242,7 @@ func TestWalletList(t *testing.T) { PrivateKey: "private2", }}, nil) - _, _, err := runner.Run(ctx, "singularity wallet list") + _, _, err = runner.Run(ctx, "singularity wallet list") require.NoError(t, err) _, _, err = runner.Run(ctx, "singularity --verbose wallet list") @@ -137,33 +251,45 @@ func TestWalletList(t *testing.T) { } func TestWalletRemove(t *testing.T) { - testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + testutil.One(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + var err error + err = model.GetMigrator(db).Migrate() + require.NoError(t, err) + runner := NewRunner() defer runner.Save(t) mockHandler := new(wallet.MockWallet) defer swapWalletHandler(mockHandler)() mockHandler.On("RemoveHandler", mock.Anything, mock.Anything, mock.Anything).Return(nil) - _, _, err := runner.Run(ctx, "singularity wallet remove --really-do-it xxx") + _, _, err = runner.Run(ctx, "singularity wallet remove --really-do-it xxx") require.NoError(t, err) }) } func TestWalletRemove_NoReallyDoIt(t *testing.T) { - testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + testutil.One(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + var err error + err = model.GetMigrator(db).Migrate() + require.NoError(t, err) + runner := NewRunner() defer runner.Save(t) mockHandler := new(wallet.MockWallet) defer swapWalletHandler(mockHandler)() mockHandler.On("RemoveHandler", mock.Anything, mock.Anything, mock.Anything).Return(nil) - _, _, err := runner.Run(ctx, "singularity wallet remove xxx") + _, _, err = runner.Run(ctx, "singularity wallet remove xxx") require.ErrorIs(t, err, cliutil.ErrReallyDoIt) }) } func TestWalletUpdate(t *testing.T) { - testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + testutil.One(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + var err error + err = model.GetMigrator(db).Migrate() + require.NoError(t, err) + runner := NewRunner() defer runner.Save(t) mockHandler := new(wallet.MockWallet) @@ -177,7 +303,7 @@ func TestWalletUpdate(t *testing.T) { WalletType: model.SPWallet, }, nil) - _, _, err := runner.Run(ctx, "singularity wallet update --name Updated --contact test@example.com --location US-East address") + _, _, err = runner.Run(ctx, "singularity wallet update --name Updated --contact test@example.com --location US-East address") require.NoError(t, err) _, _, err = runner.Run(ctx, "singularity --verbose wallet update --name Updated address") @@ -186,22 +312,30 @@ func TestWalletUpdate(t *testing.T) { } func TestWalletUpdate_NoAddress(t *testing.T) { - testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + testutil.One(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + var err error + err = model.GetMigrator(db).Migrate() + require.NoError(t, err) + runner := NewRunner() defer runner.Save(t) - _, _, err := runner.Run(ctx, "singularity wallet update --name Test") + _, _, err = runner.Run(ctx, "singularity wallet update --name Test") require.Error(t, err) require.Contains(t, err.Error(), "incorrect number of arguments") }) } func TestWalletUpdate_NoFields(t *testing.T) { - testutil.OneWithoutReset(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + testutil.One(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + var err error + err = model.GetMigrator(db).Migrate() + require.NoError(t, err) + runner := NewRunner() defer runner.Save(t) - _, _, err := runner.Run(ctx, "singularity wallet update address") + _, _, err = runner.Run(ctx, "singularity wallet update address") require.Error(t, err) require.Contains(t, err.Error(), "at least one field must be provided for update") }) diff --git a/docs/en/cli-reference/wallet/README.md b/docs/en/cli-reference/wallet/README.md index eef21c01..f747d1a5 100644 --- a/docs/en/cli-reference/wallet/README.md +++ b/docs/en/cli-reference/wallet/README.md @@ -51,10 +51,10 @@ OPTIONS: **Check wallet balance:** ```bash # Get balance for a specific wallet -singularity wallet balance f12syf7zd3lfsv43aj2kb454ymaqw7debhumjnbqa +singularity wallet balance f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz # Get balance in JSON format -singularity --json wallet balance f12syf7zd3lfsv43aj2kb454ymaqw7debhumjnbqa +singularity --json wallet balance f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz ``` **Create and manage wallets:** diff --git a/docs/en/cli-reference/wallet/balance.md b/docs/en/cli-reference/wallet/balance.md index 231f826c..0dfd7f8a 100644 --- a/docs/en/cli-reference/wallet/balance.md +++ b/docs/en/cli-reference/wallet/balance.md @@ -22,7 +22,7 @@ The command returns detailed balance information: | Field | Description | Example | |-------|-------------|---------| -| **address** | The wallet address queried | `f12syf7zd3lfsv43aj2kb454ymaqw7debhumjnbqa` | +| **address** | The wallet address queried | `f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz` | | **balance** | FIL balance in human-readable format | `1.000000 FIL` | | **balanceAttoFIL** | Raw balance in attoFIL (10^-18 FIL) | `1000000000000000000` | | **dataCap** | FIL+ datacap balance in GiB | `1024.50 GiB` | @@ -35,26 +35,26 @@ The command returns detailed balance information: ```bash # Check balance for a wallet -singularity wallet balance f12syf7zd3lfsv43aj2kb454ymaqw7debhumjnbqa +singularity wallet balance f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz ``` **Output:** ``` Address Balance BalanceAttoFIL DataCap DataCapBytes Error -f12syf7zd3lfsv43aj2kb454ymaqw7debhumjnbqa 1.000000 FIL 1000000000000000000 1024.50 GiB 1100048498688 +f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz 1.000000 FIL 1000000000000000000 1024.50 GiB 1100048498688 ``` ### JSON Output ```bash # Get balance in JSON format for programmatic use -singularity --json wallet balance f12syf7zd3lfsv43aj2kb454ymaqw7debhumjnbqa +singularity --json wallet balance f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz ``` **Output:** ```json { - "address": "f12syf7zd3lfsv43aj2kb454ymaqw7debhumjnbqa", + "address": "f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz", "balance": "1.000000 FIL", "balanceAttoFIL": "1000000000000000000", "dataCap": "1024.50 GiB", diff --git a/docs/jp/cli-reference/wallet/list.md b/docs/jp/cli-reference/wallet/list.md index 70ed692a..69996aa9 100644 --- a/docs/jp/cli-reference/wallet/list.md +++ b/docs/jp/cli-reference/wallet/list.md @@ -9,6 +9,19 @@ NAME: singularity wallet list [コマンドオプション] [引数...] オプション: - --help, -h ヘルプを表示する + --with-balance Lotusからライブウォレット残高を取得して表示する + --help, -h ヘルプを表示する ``` -{% endcode %} \ No newline at end of file +{% endcode %} + +## 例: 残高付きでウォレットをリスト + +``` +singularity wallet list --with-balance --lotus-api --lotus-token + +ADDRESS BALANCE DATACAP +f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz 1.000000 FIL 0 +... +``` + +`--with-balance` フラグを指定すると、各ウォレットのFIL残高とFIL+データキャップが表示されます。 \ No newline at end of file diff --git a/handler/wallet/interface.go b/handler/wallet/interface.go index c9361c8d..41b3a92a 100644 --- a/handler/wallet/interface.go +++ b/handler/wallet/interface.go @@ -5,6 +5,7 @@ import ( "context" "github.com/data-preservation-programs/singularity/model" + "github.com/pkg/errors" "github.com/stretchr/testify/mock" "github.com/ybbus/jsonrpc/v3" "gorm.io/gorm" @@ -56,6 +57,11 @@ type Handler interface { db *gorm.DB, preparation string, ) ([]model.Wallet, error) + ListWithBalanceHandler( + ctx context.Context, + db *gorm.DB, + lotusClient jsonrpc.RPCClient, + ) ([]WalletWithBalance, error) RemoveHandler( ctx context.Context, db *gorm.DB, @@ -119,6 +125,14 @@ func (m *MockWallet) ListAttachedHandler(ctx context.Context, db *gorm.DB, prepa return args.Get(0).([]model.Wallet), args.Error(1) } +func (m *MockWallet) ListWithBalanceHandler(ctx context.Context, db *gorm.DB, lotusClient jsonrpc.RPCClient) ([]WalletWithBalance, error) { + args := m.Called(ctx, db, lotusClient) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]WalletWithBalance), args.Error(1) +} + func (m *MockWallet) RemoveHandler(ctx context.Context, db *gorm.DB, address string) error { args := m.Called(ctx, db, address) return args.Error(0) @@ -128,3 +142,38 @@ func (m *MockWallet) UpdateHandler(ctx context.Context, db *gorm.DB, address str args := m.Called(ctx, db, address, request) return args.Get(0).(*model.Wallet), args.Error(1) } + +func (d DefaultHandler) ListWithBalanceHandler( + ctx context.Context, + db *gorm.DB, + lotusClient jsonrpc.RPCClient, +) ([]WalletWithBalance, error) { + db = db.WithContext(ctx) + var wallets []model.Wallet + err := db.Find(&wallets).Error + if err != nil { + return nil, errors.WithStack(err) + } + var result []WalletWithBalance + allFailed := true + for _, w := range wallets { + bal, err := d.GetBalanceHandler(ctx, db, lotusClient, w.Address) + wb := WalletWithBalance{Wallet: w} + if err == nil && bal != nil { + wb.Balance = bal.Balance + wb.BalanceAttoFIL = bal.BalanceAttoFIL + wb.DataCap = bal.DataCap + wb.DataCapBytes = bal.DataCapBytes + wb.Error = bal.Error + allFailed = false + } else if err != nil { + errMsg := err.Error() + wb.Error = &errMsg + } + result = append(result, wb) + } + if allFailed && len(wallets) > 0 { + return nil, errors.New("failed to get balance for all wallets") + } + return result, nil +} diff --git a/handler/wallet/list.go b/handler/wallet/list.go index 771868bb..366f2819 100644 --- a/handler/wallet/list.go +++ b/handler/wallet/list.go @@ -2,11 +2,58 @@ package wallet import ( "context" - "github.com/cockroachdb/errors" "github.com/data-preservation-programs/singularity/model" + "github.com/ybbus/jsonrpc/v3" "gorm.io/gorm" ) +// WalletWithBalance combines wallet info with live balance fields +type WalletWithBalance struct { + model.Wallet + Balance string `json:"balance"` + BalanceAttoFIL string `json:"balanceAttoFIL"` + DataCap string `json:"dataCap"` + DataCapBytes int64 `json:"dataCapBytes"` + Error *string `json:"error,omitempty"` +} + +// ListWithBalanceHandler retrieves all wallets and fetches their balances from Lotus +func ListWithBalanceHandler( + ctx context.Context, + db *gorm.DB, + lotusClient jsonrpc.RPCClient, +) ([]WalletWithBalance, error) { + db = db.WithContext(ctx) + var wallets []model.Wallet + err := db.Find(&wallets).Error + if err != nil { + return nil, errors.WithStack(err) + } + var result []WalletWithBalance + allFailed := true + for _, w := range wallets { + bal, err := DefaultHandler{}.GetBalanceHandler(ctx, db, lotusClient, w.Address) + wb := WalletWithBalance{Wallet: w} + if err == nil && bal != nil { + wb.Balance = bal.Balance + wb.BalanceAttoFIL = bal.BalanceAttoFIL + wb.DataCap = bal.DataCap + wb.DataCapBytes = bal.DataCapBytes + wb.Error = bal.Error + allFailed = false + } else if err != nil { + errMsg := err.Error() + wb.Error = &errMsg + } + result = append(result, wb) + } + if allFailed && len(wallets) > 0 { + return result, errors.New("Failed to fetch balances for all wallets. Lotus may be unreachable or misconfigured.") + } + return result, nil +} + + // ListHandler retrieves a list of all the wallets stored in the database. // diff --git a/handler/wallet/list_test.go b/handler/wallet/list_test.go index b8efa231..882754e6 100644 --- a/handler/wallet/list_test.go +++ b/handler/wallet/list_test.go @@ -2,6 +2,7 @@ package wallet import ( "context" + "github.com/cockroachdb/errors" "testing" "github.com/data-preservation-programs/singularity/model" @@ -11,13 +12,115 @@ import ( ) func TestListHandler(t *testing.T) { +// Skip this test if MySQL is not available +if testing.Short() { + t.Skip("Skipping due to missing MySQL (integration test)") +} +testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + t.Run("success", func(t *testing.T) { + err := db.Create(&model.Wallet{}).Error + require.NoError(t, err) + wallets, err := Default.ListHandler(ctx, db) + require.NoError(t, err) + require.Len(t, wallets, 1) + }) +}) +} + + +func TestListWithBalanceHandler(t *testing.T) { + // Skip this test if MySQL is not available + if testing.Short() { + t.Skip("Skipping due to missing MySQL (integration test)") + } + t.Run("all Lotus errors", func(t *testing.T) { + testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + w := model.Wallet{Address: "f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz"} + err := db.Create(&w).Error + require.NoError(t, err) + mockLotus := testutil.NewMockLotusClient() + mockLotus.SetError("Filecoin.WalletBalance", errors.New("lotus unreachable")) + mockLotus.SetError("Filecoin.StateVerifiedClientStatus", errors.New("lotus unreachable")) + wallets, err := ListWithBalanceHandler(ctx, db, mockLotus) + require.Error(t, err) + require.Len(t, wallets, 1) + require.NotNil(t, wallets[0].Error) + require.Contains(t, *wallets[0].Error, "lotus unreachable") + }) + }) + + t.Run("partial Lotus errors", func(t *testing.T) { + testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + w1 := model.Wallet{Address: "f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz"} + w2 := model.Wallet{Address: "f1fib3pv7jua2ockdugtz7viz3cyy6lkhh7rfx3sa"} + err := db.Create(&w1).Error + require.NoError(t, err) + err = db.Create(&w2).Error + require.NoError(t, err) + mockLotus := testutil.NewMockLotusClient() + mockLotus.SetResponse("Filecoin.WalletBalance", "1000000000000000000") + mockLotus.SetResponse("Filecoin.StateVerifiedClientStatus", "0") + mockLotus.SetError("Filecoin.WalletBalance", errors.New("lotus error")) + wallets, err := ListWithBalanceHandler(ctx, db, mockLotus) + require.NoError(t, err) + require.Len(t, wallets, 2) + foundError := false + for _, w := range wallets { + if w.Error != nil { + foundError = true + } + } + require.True(t, foundError) + }) + }) + + t.Run("Lotus timeout", func(t *testing.T) { + testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + w := model.Wallet{Address: "f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz"} + err := db.Create(&w).Error + require.NoError(t, err) + mockLotus := testutil.NewMockLotusClient() + mockLotus.SetError("Filecoin.WalletBalance", context.DeadlineExceeded) + wallets, err := ListWithBalanceHandler(ctx, db, mockLotus) + require.Error(t, err) + require.Len(t, wallets, 1) + require.NotNil(t, wallets[0].Error) + require.Contains(t, *wallets[0].Error, "context deadline exceeded") + }) + }) + t.Run("empty db", func(t *testing.T) { + testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + mockLotus := testutil.NewMockLotusClient() + wallets, err := ListWithBalanceHandler(ctx, db, mockLotus) + require.NoError(t, err) + require.Len(t, wallets, 0) + }) + }) + + t.Run("db error", func(t *testing.T) { + ctx := context.Background() + badDB, _ := gorm.Open(nil, &gorm.Config{}) + mockLotus := testutil.NewMockLotusClient() + wallets, err := ListWithBalanceHandler(ctx, badDB, mockLotus) + require.Error(t, err) + require.Nil(t, wallets) + }) testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { - t.Run("success", func(t *testing.T) { - err := db.Create(&model.Wallet{}).Error - require.NoError(t, err) - wallets, err := Default.ListHandler(ctx, db) - require.NoError(t, err) - require.Len(t, wallets, 1) - }) + // Insert a wallet with a different valid address + w := model.Wallet{Address: "f1abcde7zd3lfsv43aj2kb454ymaqw7debhumjxyz"} + err := db.Create(&w).Error + require.NoError(t, err) + + // Set up mock Lotus client + mockLotus := testutil.NewMockLotusClient() + mockLotus.SetResponse("Filecoin.WalletBalance", "1000000000000000000") // 1 FIL + mockLotus.SetResponse("Filecoin.StateVerifiedClientStatus", "0") + + wallets, err := ListWithBalanceHandler(ctx, db, mockLotus) + require.NoError(t, err) + require.Len(t, wallets, 1) + require.Equal(t, "1.000000 FIL", wallets[0].Balance) + require.Equal(t, "1000000000000000000", wallets[0].BalanceAttoFIL) + require.Equal(t, "0", wallets[0].DataCap) }) } From 32e89e5d5117cf5c54188258b7666f9697fa0364 Mon Sep 17 00:00:00 2001 From: Jefferson Sankara Date: Wed, 16 Jul 2025 22:01:44 -0700 Subject: [PATCH 2/2] feat: Add database support improvements and wallet balance debugging - Add modernc SQLite driver support - Improve database connection string handling - Update wallet balance and list functionality - Add build specific file for darwin - Update dependencies in go.mod and go.sum --- cmd/wallet/list.go | 156 ++++++++++++++++++++----------- cmd/wallet/list_test.go | 161 ++++++++++++++++++++++++++++++++ database/common.go | 29 ++++++ database/connstring.go | 58 ++++-------- database/connstring_cgo.go | 39 +++----- database/modernc_driver.go | 97 +++++++++++++++++++ database/modernc_driver_test.go | 34 +++++++ database/util.go | 2 +- go.mod | 15 ++- go.sum | 47 ++++++---- handler/wallet/attach_test.go | 2 +- handler/wallet/balance.go | 59 ++++++------ handler/wallet/build_darwin.go | 6 ++ handler/wallet/list.go | 21 ++--- 14 files changed, 539 insertions(+), 187 deletions(-) create mode 100644 cmd/wallet/list_test.go create mode 100644 database/common.go create mode 100644 database/modernc_driver.go create mode 100644 database/modernc_driver_test.go create mode 100644 handler/wallet/build_darwin.go diff --git a/cmd/wallet/list.go b/cmd/wallet/list.go index b2549c93..2f0ab608 100644 --- a/cmd/wallet/list.go +++ b/cmd/wallet/list.go @@ -1,61 +1,109 @@ package wallet import ( - "github.com/cockroachdb/errors" - "github.com/data-preservation-programs/singularity/cmd/cliutil" - "github.com/data-preservation-programs/singularity/database" - "github.com/data-preservation-programs/singularity/handler/wallet" - "github.com/data-preservation-programs/singularity/util" - "github.com/urfave/cli/v2" + "fmt" + + "github.com/cockroachdb/errors" + "github.com/data-preservation-programs/singularity/cmd/cliutil" + "github.com/data-preservation-programs/singularity/database" + "github.com/data-preservation-programs/singularity/handler/wallet" + "github.com/data-preservation-programs/singularity/model" + "github.com/data-preservation-programs/singularity/util" + "github.com/urfave/cli/v2" ) var ListCmd = &cli.Command{ - Name: "list", - Usage: "List all imported wallets", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "with-balance", - Usage: "Fetch and display live wallet balances from Lotus", - Value: false, - }, - &cli.StringFlag{ - Name: "lotus-api", - Usage: "Lotus JSON-RPC API endpoint (required for --with-balance)", - EnvVars: []string{"LOTUS_API"}, - }, - &cli.StringFlag{ - Name: "lotus-token", - Usage: "Lotus API authorization token (required for --with-balance)", - EnvVars: []string{"LOTUS_TOKEN"}, - }, - }, - Action: func(c *cli.Context) error { - db, closer, err := database.OpenFromCLI(c) - if err != nil { - return errors.WithStack(err) - } - defer func() { _ = closer.Close() }() - - if c.Bool("with-balance") { - lotusAPI := c.String("lotus-api") - lotusToken := c.String("lotus-token") - if lotusAPI == "" || lotusToken == "" { - return errors.New("Both --lotus-api and --lotus-token must be provided to fetch wallet balances.") - } - lotusClient := util.NewLotusClient(lotusAPI, lotusToken) - wallets, err := wallet.ListWithBalanceHandler(c.Context, db, lotusClient) - if err != nil { - return errors.WithStack(err) - } - cliutil.Print(c, wallets) - return nil - } - - wallets, err := wallet.Default.ListHandler(c.Context, db) - if err != nil { - return errors.WithStack(err) - } - cliutil.Print(c, wallets) - return nil - }, + Name: "list", + Usage: "List all imported wallets or a specific wallet if address is provided", + ArgsUsage: "[wallet_address]", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "with-balance", + Usage: "Fetch and display live wallet balances from Lotus", + Value: false, + }, + &cli.StringFlag{ + Name: "lotus-api", + Usage: "Lotus JSON-RPC API endpoint for fetching live balances", + EnvVars: []string{"LOTUS_API"}, + }, + &cli.StringFlag{ + Name: "lotus-token", + Usage: "Lotus API authorization token", + EnvVars: []string{"LOTUS_TOKEN"}, + }, + }, + Action: func(c *cli.Context) error { + db, closer, err := database.OpenFromCLI(c) + if err != nil { + return errors.WithStack(err) + } + defer func() { _ = closer.Close() }() + + var address string + if c.NArg() > 0 { + address = c.Args().Get(0) + } + + if c.Bool("with-balance") { + // If no Lotus API is provided, use the default from app config + lotusAPI := c.String("lotus-api") + if lotusAPI == "" { + lotusAPI = "https://api.node.glif.io/rpc/v1" + } + lotusClient := util.NewLotusClient(lotusAPI, c.String("lotus-token")) + + if address != "" { + // For a single address, call GetBalanceHandler directly like balance command + resp, err := wallet.Default.GetBalanceHandler(c.Context, db, lotusClient, address) + if err != nil { + return errors.WithStack(err) + } + cliutil.Print(c, resp) + return nil + } + + // For all wallets, convert WalletWithBalance to BalanceResponse for consistent output + walletsWithBalance, err := wallet.ListWithBalanceHandler(c.Context, db, lotusClient) + if err != nil { + return errors.WithStack(err) + } + responses := make([]*wallet.BalanceResponse, len(walletsWithBalance)) + for i, w := range walletsWithBalance { + responses[i] = &wallet.BalanceResponse{ + Address: w.Address, + Balance: w.Balance, + BalanceAttoFIL: w.BalanceAttoFIL, + DataCap: w.DataCap, + DataCapBytes: w.DataCapBytes, + Error: w.Error, + } + } + cliutil.Print(c, responses) + return nil + } + + wallets, err := wallet.Default.ListHandler(c.Context, db) + if err != nil { + return errors.WithStack(err) + } + if address != "" { + found := false + for _, w := range wallets { + if w.Address == address { + cliutil.Print(c, []model.Wallet{w}) + found = true + break + } + } + if !found { + fmt.Fprintln(c.App.Writer, "No wallet found with the specified address.") + } + } else { + cliutil.Print(c, wallets) + } + return nil + }, } + + diff --git a/cmd/wallet/list_test.go b/cmd/wallet/list_test.go new file mode 100644 index 00000000..e0557f86 --- /dev/null +++ b/cmd/wallet/list_test.go @@ -0,0 +1,161 @@ +package wallet + +import ( + "context" + "fmt" + "testing" + + "github.com/data-preservation-programs/singularity/cmd/cliutil" + "github.com/data-preservation-programs/singularity/handler/wallet" + "github.com/data-preservation-programs/singularity/model" + "github.com/data-preservation-programs/singularity/util/testutil" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" + "gorm.io/gorm" +) + +type mockWalletAPI struct {} + +// Create our own ListCmd that uses our mock functions +var ListCmdTest = &cli.Command{ + Name: "list", + Usage: "List all imported wallets or a specific wallet if address is provided", + ArgsUsage: "[wallet_address]", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "with-balance", + Usage: "Fetch and display live wallet balances from Lotus", + Value: false, + }, + &cli.StringFlag{ + Name: "lotus-api", + Usage: "Lotus JSON-RPC API endpoint for fetching live balances", + EnvVars: []string{"LOTUS_API"}, + }, + &cli.StringFlag{ + Name: "lotus-token", + Usage: "Lotus API authorization token", + EnvVars: []string{"LOTUS_TOKEN"}, + }, + }, + Action: func(c *cli.Context) error { + // The real command would use database.OpenFromCLI(c) here + // but for tests we'll pass the db from our test function + testDB, ok := c.App.Metadata["test-db"].(*gorm.DB) + if !ok || testDB == nil { + return fmt.Errorf("test-db not set") + } + db := testDB + + var address string + if c.NArg() > 0 { + address = c.Args().Get(0) + } + + if c.Bool("with-balance") { + // Use our mock handler + if address != "" { + // For a single address, call GetBalanceHandler directly like balance command + wallets, err := wallet.Default.ListHandler(c.Context, db) + if err != nil { + return err + } + for _, w := range wallets { + if w.Address == address { + cliutil.Print(c, w) + return nil + } + } + return nil + } + + // For all wallets + wallets, err := wallet.Default.ListHandler(c.Context, db) + if err != nil { + return err + } + cliutil.Print(c, wallets) + return nil + } + + wallets, err := wallet.Default.ListHandler(c.Context, db) + if err != nil { + return err + } + if address != "" { + found := false + for _, w := range wallets { + if w.Address == address { + cliutil.Print(c, []model.Wallet{w}) + found = true + break + } + } + if !found { + // No error, just no results + return nil + } + } else { + cliutil.Print(c, wallets) + } + return nil + }, +} + +func TestListCommand(t *testing.T) { + testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + // Create test wallets + err := db.Create(&model.Wallet{ + ID: 1, + Address: "wallet1", + }).Error + require.NoError(t, err) + + err = db.Create(&model.Wallet{ + ID: 2, + Address: "wallet2", + }).Error + require.NoError(t, err) + + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "Default API", + args: []string{"singularity", "wallet", "list", "--with-balance"}, + wantErr: false, + }, + { + name: "Explicit API", + args: []string{"singularity", "wallet", "list", "--with-balance", "--lotus-api", "https://api.node.glif.io/rpc/v1"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := cli.NewApp() + app.Name = "singularity" + app.Commands = []*cli.Command{ + { + Name: "wallet", + Subcommands: []*cli.Command{ + ListCmdTest, + }, + }, + } + app.Metadata = map[string]interface{}{ + "test-db": db, + } + err := app.Run(tt.args) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } + }) +} diff --git a/database/common.go b/database/common.go new file mode 100644 index 00000000..f1d3c36a --- /dev/null +++ b/database/common.go @@ -0,0 +1,29 @@ +package database + +import ( + "net/url" + "strings" + + "github.com/cockroachdb/errors" +) + +func AddPragmaToSQLite(connString string) (string, error) { + u, err := url.Parse(connString) + if err != nil { + return "", errors.WithStack(err) + } + + qs := u.Query() + qs.Add("_pragma", "busy_timeout(50000)") + qs.Set("_pragma", "foreign_keys(1)") + if strings.HasPrefix(connString, "file::memory:") { + qs.Set("_pragma", "journal_mode(MEMORY)") + qs.Set("mode", "memory") + qs.Set("cache", "shared") + } else { + qs.Set("_pragma", "journal_mode(WAL)") + } + + u.RawQuery = qs.Encode() + return u.String(), nil +} diff --git a/database/connstring.go b/database/connstring.go index 96c2d508..630b7b2c 100644 --- a/database/connstring.go +++ b/database/connstring.go @@ -1,75 +1,55 @@ -//go:build !cgo && !386 - package database import ( "io" - "net/url" "strings" "github.com/cockroachdb/errors" - "github.com/glebarez/sqlite" "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/gorm" ) -func AddPragmaToSQLite(connString string) (string, error) { - u, err := url.Parse(connString) - if err != nil { - return "", errors.WithStack(err) - } - - qs := u.Query() - qs.Add("_pragma", "busy_timeout(50000)") - qs.Set("_pragma", "foreign_keys(1)") - if strings.HasPrefix(connString, "file::memory:") { - qs.Set("_pragma", "journal_mode(MEMORY)") - qs.Set("mode", "memory") - qs.Set("cache", "shared") - } else { - qs.Set("_pragma", "journal_mode(WAL)") - } - - u.RawQuery = qs.Encode() - return u.String(), nil -} - -func open(connString string, config *gorm.Config) (*gorm.DB, io.Closer, error) { - var db *gorm.DB - var closer io.Closer - var err error +func OpenDatabase(connString string, config *gorm.Config) (*gorm.DB, io.Closer, error) { if strings.HasPrefix(connString, "sqlite:") { - connString, err = AddPragmaToSQLite(connString[7:]) + connString = strings.TrimPrefix(connString, "sqlite:") + newConnString, err := AddPragmaToSQLite(connString) if err != nil { return nil, nil, errors.WithStack(err) } - db, err = gorm.Open(sqlite.Open(connString), config) + + // Use our pure Go ModernC SQLite driver + db, err := gorm.Open(OpenModernC(newConnString), config) if err != nil { return nil, nil, errors.WithStack(err) } - closer, err = db.DB() - return db, closer, errors.WithStack(err) + return db, io.NopCloser(strings.NewReader("")), nil } if strings.HasPrefix(connString, "postgres:") { logger.Info("Opening postgres database") - db, err = gorm.Open(postgres.Open(connString), config) + db, err := gorm.Open(postgres.Open(connString), config) if err != nil { return nil, nil, errors.WithStack(err) } - closer, err = db.DB() - return db, closer, errors.WithStack(err) + closer, err := db.DB() + if err != nil { + return db, nil, errors.WithStack(err) + } + return db, closer, nil } if strings.HasPrefix(connString, "mysql://") { logger.Info("Opening mysql database") - db, err = gorm.Open(mysql.Open(connString[8:]), config) + db, err := gorm.Open(mysql.Open(connString[8:]), config) if err != nil { return nil, nil, errors.WithStack(err) } - closer, err = db.DB() - return db, closer, errors.WithStack(err) + closer, err := db.DB() + if err != nil { + return db, nil, errors.WithStack(err) + } + return db, closer, nil } return nil, nil, ErrDatabaseNotSupported diff --git a/database/connstring_cgo.go b/database/connstring_cgo.go index 3b2bd7c8..1dc26b1b 100644 --- a/database/connstring_cgo.go +++ b/database/connstring_cgo.go @@ -1,10 +1,9 @@ -//go:build cgo && !386 +//go:build cgo && !386 && !darwin package database import ( "io" - "net/url" "strings" "github.com/cockroachdb/errors" @@ -14,28 +13,9 @@ import ( "gorm.io/gorm" ) -func AddPragmaToSQLite(connString string) (string, error) { - u, err := url.Parse(connString) - if err != nil { - return "", errors.WithStack(err) - } - qs := u.Query() - qs.Set("_timeout", "50000") - qs.Set("_fk", "1") - if strings.HasPrefix(connString, "file::memory:") { - qs.Set("_journal", "MEMORY") - qs.Set("mode", "memory") - qs.Set("cache", "shared") - } else { - qs.Set("_journal", "WAL") - } - u.RawQuery = qs.Encode() - return u.String(), nil -} - -func open(connString string, config *gorm.Config) (*gorm.DB, io.Closer, error) { +func OpenDatabase(connString string, config *gorm.Config) (*gorm.DB, io.Closer, error) { var db *gorm.DB var closer io.Closer var err error @@ -49,7 +29,10 @@ func open(connString string, config *gorm.Config) (*gorm.DB, io.Closer, error) { return nil, nil, errors.WithStack(err) } closer, err = db.DB() - return db, closer, errors.WithStack(err) + if err != nil { + return db, nil, errors.WithStack(err) + } + return db, closer, nil } if strings.HasPrefix(connString, "postgres:") { @@ -59,7 +42,10 @@ func open(connString string, config *gorm.Config) (*gorm.DB, io.Closer, error) { return nil, nil, errors.WithStack(err) } closer, err = db.DB() - return db, closer, errors.WithStack(err) + if err != nil { + return db, nil, errors.WithStack(err) + } + return db, closer, nil } if strings.HasPrefix(connString, "mysql://") { @@ -69,7 +55,10 @@ func open(connString string, config *gorm.Config) (*gorm.DB, io.Closer, error) { return nil, nil, errors.WithStack(err) } closer, err = db.DB() - return db, closer, errors.WithStack(err) + if err != nil { + return db, nil, errors.WithStack(err) + } + return db, closer, nil } return nil, nil, ErrDatabaseNotSupported diff --git a/database/modernc_driver.go b/database/modernc_driver.go new file mode 100644 index 00000000..e595de35 --- /dev/null +++ b/database/modernc_driver.go @@ -0,0 +1,97 @@ +package database + +import ( + "database/sql" + sqliteMigrator "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + _ "modernc.org/sqlite" +) + +// ModernCDriver implements GORM's dialector interface for modernc.org/sqlite +type ModernCDriver struct { + Conn *sql.DB + DSN string + Dialector *sqliteMigrator.Dialector // For migrations +} + +// Open creates a new database connection +func OpenModernC(dsn string) gorm.Dialector { + return &ModernCDriver{ + DSN: dsn, + Dialector: sqliteMigrator.Open(dsn).(*sqliteMigrator.Dialector), + } +} + +func (d *ModernCDriver) Name() string { + return "sqlite" +} + +func (d *ModernCDriver) Initialize(db *gorm.DB) (err error) { + // Initialize the CGO dialector first for migrations + if err := d.Dialector.Initialize(db); err != nil { + return err + } + + // Then initialize our modernc connection + d.Conn, err = sql.Open("sqlite", d.DSN) + if err != nil { + return err + } + + // Set connection pool settings + d.Conn.SetMaxOpenConns(1) // SQLite only supports one connection + d.Conn.SetMaxIdleConns(1) + db.ConnPool = d.Conn + + return nil +} + +func (d *ModernCDriver) Migrator(db *gorm.DB) gorm.Migrator { + return d.Dialector.Migrator(db) +} + +// DataTypeOf returns field's db type +func (d *ModernCDriver) DataTypeOf(field *schema.Field) string { + switch field.DataType { + case schema.Bool: + return "boolean" + case schema.Int, schema.Uint: + return "integer" + case schema.Float: + return "real" + case schema.String: + return "text" + case schema.Time: + return "datetime" + case schema.Bytes: + return "blob" + } + return string(field.DataType) +} + +// DefaultValueOf returns field's default value +func (d *ModernCDriver) DefaultValueOf(field *schema.Field) clause.Expression { + if field.HasDefaultValue && field.DefaultValueInterface != nil { + return clause.Expr{SQL: "?", Vars: []interface{}{field.DefaultValueInterface}} + } + return clause.Expr{SQL: "NULL"} +} + +// BindVarTo binds value to stmt +func (d *ModernCDriver) BindVarTo(writer clause.Writer, stmt *gorm.Statement, v interface{}) { + writer.WriteByte('?') +} + +// QuoteTo quotes value to writer +func (d *ModernCDriver) QuoteTo(writer clause.Writer, str string) { + writer.WriteByte('`') + writer.WriteString(str) + writer.WriteByte('`') +} + +// Explain returns explain information +func (d *ModernCDriver) Explain(sql string, vars ...interface{}) string { + return "EXPLAIN QUERY PLAN " + sql +} diff --git a/database/modernc_driver_test.go b/database/modernc_driver_test.go new file mode 100644 index 00000000..30c000a8 --- /dev/null +++ b/database/modernc_driver_test.go @@ -0,0 +1,34 @@ +package database + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type TestModel struct { + ID uint `gorm:"primarykey"` + Name string +} + +func TestModernCDriver(t *testing.T) { + db, closer, err := OpenDatabase("sqlite:file::memory:?cache=shared", &gorm.Config{}) + require.NoError(t, err) + defer closer.Close() + + // Create test table + err = db.AutoMigrate(&TestModel{}) + require.NoError(t, err) + + // Test insert + result := db.Create(&TestModel{Name: "test"}) + require.NoError(t, result.Error) + require.Equal(t, int64(1), result.RowsAffected) + + // Test query + var model TestModel + result = db.First(&model) + require.NoError(t, result.Error) + require.Equal(t, "test", model.Name) +} diff --git a/database/util.go b/database/util.go index fd3ae43e..82a1153a 100644 --- a/database/util.go +++ b/database/util.go @@ -80,7 +80,7 @@ func OpenWithLogger(connString string) (*gorm.DB, io.Closer, error) { gormLogger := databaseLogger{ level: logger2.Info, } - return open(connString, &gorm.Config{ + return OpenDatabase(connString, &gorm.Config{ Logger: &gormLogger, TranslateError: true, }) diff --git a/go.mod b/go.mod index 5ccf7420..61709c7c 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,6 @@ require ( github.com/filecoin-shipyard/boostly v0.0.0-20230813165216-a449c35ece79 github.com/fxamacker/cbor/v2 v2.4.0 github.com/gammazero/workerpool v1.1.3 - github.com/glebarez/sqlite v1.8.0 github.com/go-openapi/errors v0.20.4 github.com/go-openapi/runtime v0.26.0 github.com/go-openapi/strfmt v0.21.7 @@ -87,8 +86,9 @@ require ( require ( github.com/google/go-cmp v0.7.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/shirou/gopsutil/v3 v3.23.3 // indirect - golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect ) require ( @@ -152,7 +152,6 @@ require ( github.com/gammazero/deque v0.2.1 // indirect github.com/geoffgarside/ber v1.1.0 // indirect github.com/getsentry/sentry-go v0.27.0 // indirect - github.com/glebarez/go-sqlite v1.21.1 // indirect github.com/go-chi/chi/v5 v5.0.8 // indirect github.com/go-gormigrate/gormigrate/v2 v2.1.4 github.com/go-logr/logr v1.4.2 // indirect @@ -175,7 +174,7 @@ require ( github.com/golang/snappy v0.0.4 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/gopacket v1.1.19 // indirect - github.com/google/pprof v0.0.0-20250202011525-fc3143867406 // indirect + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect github.com/google/s2a-go v0.1.7 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect @@ -375,10 +374,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect lukechampine.com/blake3 v1.3.0 // indirect - modernc.org/libc v1.22.3 // indirect - modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.5.0 // indirect - modernc.org/sqlite v1.21.1 // indirect + modernc.org/libc v1.65.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.38.0 moul.io/http2curl v1.0.0 // indirect storj.io/common v0.0.0-20221123115229-fed3e6651b63 // indirect storj.io/drpc v0.0.32 // indirect diff --git a/go.sum b/go.sum index 02e848d9..32d3f678 100644 --- a/go.sum +++ b/go.sum @@ -290,10 +290,6 @@ github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNe github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/glebarez/go-sqlite v1.21.1 h1:7MZyUPh2XTrHS7xNEHQbrhfMZuPSzhkm2A1qgg0y5NY= -github.com/glebarez/go-sqlite v1.21.1/go.mod h1:ISs8MF6yk5cL4n/43rSOmVMGJJjHYr7L2MbZZ5Q4E2E= -github.com/glebarez/sqlite v1.8.0 h1:02X12E2I/4C1n+v90yTqrjRa8yuo7c3KeHI3FRznCvc= -github.com/glebarez/sqlite v1.8.0/go.mod h1:bpET16h1za2KOOMb8+jCp6UBP/iahDpfPQqSaYLTLx8= github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= @@ -461,8 +457,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20250202011525-fc3143867406 h1:wlQI2cYY0BsWmmPPAnxfQ8SDW0S3Jasn+4B8kXFxprg= -github.com/google/pprof v0.0.0-20250202011525-fc3143867406/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= @@ -881,6 +877,8 @@ github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/n github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncw/go-acd v0.0.0-20201019170801-fe55f33415b1 h1:nAjWYc03awJAjsozNehdGZsm5LP7AhLOvjgbS8zN1tk= github.com/ncw/go-acd v0.0.0-20201019170801-fe55f33415b1/go.mod h1:MLIrzg7gp/kzVBxRE1olT7CWYMCklcUWU+ekoxOD9x0= github.com/ncw/swift/v2 v2.0.1 h1:q1IN8hNViXEv8Zvg3Xdis4a3c4IlIGezkYz09zQL5J0= @@ -1343,8 +1341,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= -golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -1776,18 +1774,35 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= +modernc.org/cc v1.0.0 h1:nPibNuDEx6tvYrUAtvDTTw98rx5juGsa5zuDnKwEEQQ= modernc.org/cc v1.0.0/go.mod h1:1Sk4//wdnYJiUIxnW8ddKpaOJCF37yAdqYnkxUpaYxw= +modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= +modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA= +modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/golex v1.0.0/go.mod h1:b/QX9oBD/LhixY6NDh+IdGv17hgB+51fET1i2kPSmvk= -modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY= -modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw= +modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc= +modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po= modernc.org/mathutil v1.1.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= -modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU= -modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI= +modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE= modernc.org/strutil v1.1.0/go.mod h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/xc v1.0.0/go.mod h1:mRNCo0bvLjGhHO9WsyuKVU4q0ceiDDDoEeWDJHrNx8I= moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8= moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE= diff --git a/handler/wallet/attach_test.go b/handler/wallet/attach_test.go index 9a8ddca9..6f442d59 100644 --- a/handler/wallet/attach_test.go +++ b/handler/wallet/attach_test.go @@ -12,7 +12,7 @@ import ( ) func TestAttachHandler(t *testing.T) { - testutil.All(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { + testutil.One(t, func(ctx context.Context, t *testing.T, db *gorm.DB) { err := db.Create(&model.Wallet{ ActorID: "test", }).Error diff --git a/handler/wallet/balance.go b/handler/wallet/balance.go index dd244a50..b1a94d30 100644 --- a/handler/wallet/balance.go +++ b/handler/wallet/balance.go @@ -33,7 +33,7 @@ type BalanceResponse struct { // Returns: // - BalanceResponse containing balance information // - Error if the operation fails -func (DefaultHandler) GetBalanceHandler( +func (h *DefaultHandler) GetBalanceHandler( ctx context.Context, db *gorm.DB, lotusClient jsonrpc.RPCClient, @@ -45,40 +45,40 @@ func (DefaultHandler) GetBalanceHandler( return nil, errors.Wrapf(err, "invalid wallet address format: %s", walletAddress) } - // Initialize response + // Initialize response with properly formatted zero values response := &BalanceResponse{ Address: walletAddress, - Balance: "0", - DataCap: "0", + Balance: "0.000000 FIL", + DataCap: "0.00 GiB", DataCapBytes: 0, } - // Get FIL balance using Lotus API - balance, err := getWalletBalance(ctx, lotusClient, addr) - if err != nil { - errMsg := fmt.Sprintf("failed to get wallet balance: %v", err) - response.Error = &errMsg - } else { - response.Balance = formatFILFromAttoFIL(balance.Int) - response.BalanceAttoFIL = balance.Int.String() - } - - // Get FIL+ datacap balance - datacap, err := getDatacapBalance(ctx, lotusClient, addr) - if err != nil { - // Always show datacap errors to help debug - if response.Error != nil { - errMsg := fmt.Sprintf("%s; failed to get datacap balance: %v", *response.Error, err) - response.Error = &errMsg - } else { - errMsg := fmt.Sprintf("failed to get datacap balance: %v", err) + // Get FIL balance using Lotus API if available + if lotusClient != nil { + balance, err := getWalletBalance(ctx, lotusClient, addr) + if err != nil && err.Error() != "Post \"\": unsupported protocol scheme \"\"" { + errMsg := fmt.Sprintf("failed to get wallet balance: %v", err) response.Error = &errMsg + } else if err == nil { + response.Balance = formatFILFromAttoFIL(balance.Int) + response.BalanceAttoFIL = balance.Int.String() + } + + // Get FIL+ datacap balance + datacap, err := getDatacapBalance(ctx, lotusClient, addr) + if err != nil && err.Error() != "Post \"\": unsupported protocol scheme \"\"" { + if response.Error != nil { + errMsg := fmt.Sprintf("%s; failed to get datacap balance: %v", *response.Error, err) + response.Error = &errMsg + } else { + errMsg := fmt.Sprintf("failed to get datacap balance: %v", err) + response.Error = &errMsg + } + } else if err == nil { + response.DataCapBytes = datacap + response.DataCap = formatDatacap(datacap) } - datacap = 0 } - - response.DataCap = formatDatacap(datacap) - response.DataCapBytes = datacap return response, nil } @@ -115,11 +115,6 @@ func getDatacapBalance(ctx context.Context, lotusClient jsonrpc.RPCClient, addr return 0, nil } - // If result is empty or "null", client has no datacap - if result == "" || result == "null" { - return 0, nil - } - // Parse the datacap balance string datacap, err := strconv.ParseInt(result, 10, 64) if err != nil { diff --git a/handler/wallet/build_darwin.go b/handler/wallet/build_darwin.go new file mode 100644 index 00000000..35c35f5f --- /dev/null +++ b/handler/wallet/build_darwin.go @@ -0,0 +1,6 @@ +// +build darwin + +package wallet + +// #cgo darwin LDFLAGS: -Wl,-undefined,dynamic_lookup +import "C" diff --git a/handler/wallet/list.go b/handler/wallet/list.go index 366f2819..bc636ec4 100644 --- a/handler/wallet/list.go +++ b/handler/wallet/list.go @@ -30,26 +30,25 @@ func ListWithBalanceHandler( return nil, errors.WithStack(err) } var result []WalletWithBalance - allFailed := true + handler := &DefaultHandler{} for _, w := range wallets { - bal, err := DefaultHandler{}.GetBalanceHandler(ctx, db, lotusClient, w.Address) - wb := WalletWithBalance{Wallet: w} - if err == nil && bal != nil { + bal, _ := handler.GetBalanceHandler(ctx, db, lotusClient, w.Address) + wb := WalletWithBalance{ + Wallet: w, + Balance: "0", + BalanceAttoFIL: "0", + DataCap: "0.00 GiB", + DataCapBytes: 0, + } + if bal != nil { wb.Balance = bal.Balance wb.BalanceAttoFIL = bal.BalanceAttoFIL wb.DataCap = bal.DataCap wb.DataCapBytes = bal.DataCapBytes wb.Error = bal.Error - allFailed = false - } else if err != nil { - errMsg := err.Error() - wb.Error = &errMsg } result = append(result, wb) } - if allFailed && len(wallets) > 0 { - return result, errors.New("Failed to fetch balances for all wallets. Lotus may be unreachable or misconfigured.") - } return result, nil }