From b7f74a26087da060c08a6a72f677ab842deb0225 Mon Sep 17 00:00:00 2001 From: Jusitn Do Date: Fri, 12 Dec 2025 11:03:43 +0700 Subject: [PATCH 1/6] add code for gomobile --- go.mod | 9 +- go.sum | 20 +- mobile/mobile.go | 1391 ++++++++++++++++++++++++++++++++++++++++++++++ tools.go | 9 + 4 files changed, 1422 insertions(+), 7 deletions(-) create mode 100644 mobile/mobile.go create mode 100644 tools.go diff --git a/go.mod b/go.mod index 8d089c5..031da49 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/jrick/logrotate v1.1.2 github.com/kevinburke/nacl v0.0.0-20210405173606-cd9060f5f776 golang.org/x/crypto v0.33.0 + golang.org/x/mobile v0.0.0-20251126181937-5c265dc024c4 golang.org/x/text v0.22.0 ) @@ -46,9 +47,11 @@ require ( github.com/jrick/wsrpc/v2 v2.3.8 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect go.etcd.io/bbolt v1.3.11 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/tools v0.39.0 // indirect lukechampine.com/blake3 v1.3.0 // indirect ) -go 1.22 +go 1.24.0 diff --git a/go.sum b/go.sum index a8a2e92..1c2fdd9 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,8 @@ github.com/decred/slog v1.2.0 h1:soHAxV52B54Di3WtKLfPum9OFfWqwtf/ygf9njdfnPM= github.com/decred/slog v1.2.0/go.mod h1:kVXlGnt6DHy2fV5OjSeuvCJ0OmlmTF6LFpEPMu/fOY0= github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk= github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jrick/bitset v1.0.0 h1:Ws0PXV3PwXqWK2n7Vz6idCdrV/9OrBXgHEJi27ZB9Dw= @@ -90,12 +92,22 @@ go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/mobile v0.0.0-20251126181937-5c265dc024c4 h1:lZKReZrCBTDNaVewUp31194cua6qf65/tYg3mq1KUU0= +golang.org/x/mobile v0.0.0-20251126181937-5c265dc024c4/go.mod h1:Eq3Nh/5pFSWug2ohiudJ1iyU59SO78QFuh4qTTN++I0= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= diff --git a/mobile/mobile.go b/mobile/mobile.go new file mode 100644 index 0000000..f3ac678 --- /dev/null +++ b/mobile/mobile.go @@ -0,0 +1,1391 @@ +// Package mobile exports Decred wallet functionalities for mobile platforms. +// This package is designed to be compiled with gomobile for iOS and Android. +// +// Build cmd: gomobile bind -target=ios -o ./build/Libwallet.xcframework ./mobile +package mobile + +import ( + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "os" + "strconv" + "strings" + "sync" + "time" + + dexmnemonic "decred.org/dcrdex/client/mnemonic" + "decred.org/dcrwallet/v4/spv" + dcrwallet "decred.org/dcrwallet/v4/wallet" + "decred.org/dcrwallet/v4/wallet/udb" + "github.com/decred/dcrd/chaincfg/chainhash" + "github.com/decred/dcrd/txscript/v4/stdaddr" + "github.com/decred/libwallet/assetlog" + "github.com/decred/libwallet/dcr" + "github.com/decred/libwallet/mnemonic" + "github.com/decred/slog" + "github.com/jrick/logrotate/rotator" +) + +// ----------------------------------------------------------------------------- +// Global variables +// ----------------------------------------------------------------------------- + +var ( + mainCtx context.Context + cancelMainCtx context.CancelFunc + wg sync.WaitGroup + + logBackend *parentLogger + logMtx sync.RWMutex + log slog.Logger + + // walletsMtx protects wallets and initialized. + walletsMtx sync.RWMutex + wallets = make(map[string]*wallet) + initialized bool +) + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +const ( + // ErrCodeNotSynced is returned when the wallet must be synced to perform an + // action but is not. + ErrCodeNotSynced = 1 + + defaultAccount = "default" +) + +// SyncStatusCode represents the sync status of a wallet. +type SyncStatusCode int + +const ( + SSCNotStarted SyncStatusCode = iota + SSCFetchingCFilters + SSCFetchingHeaders + SSCDiscoveringAddrs + SSCRescanning + SSCComplete +) + +func (ssc SyncStatusCode) String() string { + return [...]string{"not started", "fetching cfilters", "fetching headers", + "discovering addresses", "rescanning", "sync complete"}[ssc] +} + +// SyncStatusRes represents the sync status response. +type SyncStatusRes struct { + SyncStatusCode int `json:"syncstatuscode"` + SyncStatus string `json:"syncstatus"` + TargetHeight int `json:"targetheight"` + NumPeers int `json:"numpeers"` + CFiltersHeight int `json:"cfiltersheight,omitempty"` + HeadersHeight int `json:"headersheight,omitempty"` + RescanHeight int `json:"rescanheight,omitempty"` +} + +// Input represents a transaction input. +type Input struct { + TxID string `json:"txid"` + Vout int `json:"vout"` +} + +// Output represents a transaction output. +type Output struct { + Address string `json:"address"` + Amount int `json:"amount"` +} + +// CreateTxReq represents a create transaction request. +type CreateTxReq struct { + Outputs []Output `json:"outputs"` + Inputs []Input `json:"inputs"` + IgnoreInputs []Input `json:"ignoreinputs"` + FeeRate int `json:"feerate"` + SendAll bool `json:"sendall"` + Password string `json:"password"` + Sign bool `json:"sign"` +} + +// CreateTxRes represents a create transaction response. +type CreateTxRes struct { + Hex string `json:"hex"` + Txid string `json:"txid"` + Fee int `json:"fee"` +} + +// BestBlockRes represents the best block response. +type BestBlockRes struct { + Hash string `json:"hash"` + Height int `json:"height"` +} + +// ListTransactionRes represents a list transaction response. +type ListTransactionRes struct { + Address string `json:"address,omitempty"` + Amount float64 `json:"amount"` + Category string `json:"category"` + Confirmations int64 `json:"confirmations"` + Height int64 `json:"height"` + Fee *float64 `json:"fee,omitempty"` + Time int64 `json:"time"` + TxID string `json:"txid"` + Vout uint32 `json:"vout"` +} + +// BirthdayStateRes represents the birthday state response. +type BirthdayStateRes struct { + Hash string `json:"hash"` + Height uint32 `json:"height"` + Time int64 `json:"time"` + SetFromHeight bool `json:"setfromheight"` + SetFromTime bool `json:"setfromtime"` +} + +// AddressesRes represents the addresses response. +type AddressesRes struct { + Used []string `json:"used"` + Unused []string `json:"unused"` + Index uint32 `json:"index"` +} + +// Config represents the wallet configuration. +type Config struct { + Name string `json:"name"` + // Allow getting unused addresses when not synced. + AllowUnsyncedAddrs bool `json:"unsyncedaddrs"` + Net string `json:"net"` + DataDir string `json:"datadir"` + // Only needed during creation. + Birthday int64 `json:"birthday"` + Pass string `json:"pass"` + Mnemonic string `json:"mnemonic"` + SeedPass string `json:"seedpass"` + // If the wallet existed before but the db was deleted to reduce + // storage, restore from the local encrypted seed using the provided + // password. Also works for watching only wallets with no password. + UseLocalSeed bool `json:"uselocalseed"` + // Only needed during watching only creation. + PubKey string `json:"pubkey"` +} + +// AddrFromExtKey represents the address from extended key request. +type AddrFromExtKey struct { + Key string `json:"key"` + Path string `json:"path"` + // Currently support types: P2PKH + AddrType string `json:"addrtype"` + UseChildBIP32Std bool `json:"usechildbip32std"` +} + +// CreateExtendedKeyReq represents the create extended key request. +// Note: Depth uses int instead of uint8 because gomobile doesn't handle uint8 well +// when generating Objective-C bindings (uint8 maps to 'byte' which conflicts with macOS types) +type CreateExtendedKeyReq struct { + Key string `json:"key"` + ParentKey string `json:"parentkey"` + ChainCode string `json:"chaincode"` + Network string `json:"network"` + Depth int `json:"depth"` + ChildN int `json:"childn"` + IsPrivate bool `json:"isprivate"` +} + +// ----------------------------------------------------------------------------- +// Internal types +// ----------------------------------------------------------------------------- + +type wallet struct { + *dcr.Wallet + log slog.Logger + + sync.WaitGroup + ctx context.Context + cancelCtx context.CancelFunc + + syncStatusMtx sync.RWMutex + syncStatusCode SyncStatusCode + targetHeight, cfiltersHeight, headersHeight, rescanHeight, numPeers int + rescanning, allowUnsyncedAddrs bool +} + +type parentLogger struct { + *slog.Backend + rotator *rotator.Rotator + lvl slog.Level +} + +func newParentLogger(rotator *rotator.Rotator, lvl slog.Level) *parentLogger { + return &parentLogger{ + Backend: slog.NewBackend(rotator), + rotator: rotator, + lvl: lvl, + } +} + +func newParentStdOutLogger(lvl slog.Level) *parentLogger { + backend := slog.NewBackend(os.Stdout) + return &parentLogger{ + Backend: backend, + lvl: lvl, + } +} + +func (pl *parentLogger) SubLogger(name string) slog.Logger { + logger := pl.Logger(name) + logger.SetLevel(pl.lvl) + return logger +} + +func (pl *parentLogger) Close() error { + if pl.rotator != nil { + return pl.rotator.Close() + } + return nil +} + +// ----------------------------------------------------------------------------- +// Helper functions +// ----------------------------------------------------------------------------- + +func loadedWallet(name string) (*wallet, bool) { + walletsMtx.RLock() + defer walletsMtx.RUnlock() + + w, ok := wallets[name] + if !ok { + logMtx.RLock() + if log != nil { + log.Debugf("attempted to use an unloaded wallet %q", name) + } + logMtx.RUnlock() + } + return w, ok +} + +// ----------------------------------------------------------------------------- +// Core Functions +// ----------------------------------------------------------------------------- + +// Initialize initializes the libwallet mobile library. +func Initialize(logDir, logLvl string) (string, error) { + walletsMtx.Lock() + defer walletsMtx.Unlock() + if initialized { + return "", errors.New("duplicate initialization") + } + + lvl, ok := slog.LevelFromString(logLvl) + if !ok { + return "", fmt.Errorf("unknown log level %q", logLvl) + } + + if logDir != "" { + logSpinner, err := assetlog.NewRotator(logDir, "dcrwallet.log") + if err != nil { + return "", fmt.Errorf("error initializing log rotator: %v", err) + } + + logBackend = newParentLogger(logSpinner, lvl) + err = dcr.InitGlobalLogging(logDir, logBackend, lvl) + if err != nil { + return "", fmt.Errorf("error initializing logger for external pkgs: %v", err) + } + } else { + logBackend = newParentStdOutLogger(lvl) + } + + logMtx.Lock() + log = logBackend.SubLogger("APP") + logMtx.Unlock() + + mainCtx, cancelMainCtx = context.WithCancel(context.Background()) + + initialized = true + return "libwallet mobile initialized", nil +} + +// Shutdown shuts down the libwallet mobile library. +func Shutdown() (string, error) { + walletsMtx.Lock() + defer walletsMtx.Unlock() + if !initialized { + return "", errors.New("not initialized") + } + + logMtx.RLock() + log.Debug("libwallet mobile shutting down") + logMtx.RUnlock() + + for _, w := range wallets { + if err := w.CloseWallet(); err != nil { + w.log.Errorf("close wallet error: %v", err) + } + } + wallets = make(map[string]*wallet) + + // Stop all remaining background processes and wait for them to stop. + cancelMainCtx() + wg.Wait() + + // Close the logger backend as the last step. + logMtx.Lock() + log.Debug("libwallet mobile shutdown") + logBackend.Close() + logBackend = nil + logMtx.Unlock() + + initialized = false + return "libwallet mobile shutdown", nil +} + +// ----------------------------------------------------------------------------- +// Wallet Management +// ----------------------------------------------------------------------------- + +// CreateWallet creates a new wallet with the given config JSON. +func CreateWallet(configJSON string) (string, error) { + walletsMtx.Lock() + defer walletsMtx.Unlock() + if !initialized { + return "", errors.New("libwallet is not initialized") + } + + var cfg Config + if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { + return "", fmt.Errorf("malformed config: %v", err) + } + + name := cfg.Name + if _, exists := wallets[name]; exists { + return "", fmt.Errorf("wallet already exists with name: %q", name) + } + + logger := logBackend.SubLogger(name) + params := dcr.CreateWalletParams{ + OpenWalletParams: dcr.OpenWalletParams{ + Net: cfg.Net, + DataDir: cfg.DataDir, + DbDriver: "bdb", // use badgerdb for mobile! + Logger: logger, + }, + Pass: []byte(cfg.Pass), + } + + var recoveryConfig *dcr.RecoveryCfg + if cfg.Mnemonic != "" { + var ( + seed []byte + birthday time.Time + seedType dcr.SeedType + err error + ) + nWords := len(strings.Fields(cfg.Mnemonic)) + switch nWords { + case 15: + seed, birthday, err = dexmnemonic.DecodeMnemonic(cfg.Mnemonic) + seedType = dcr.STFifteenWords + case 12: + seed, err = mnemonic.DecodeMnemonic(cfg.Mnemonic) + birthday = time.Unix(cfg.Birthday, 0) + seedType = dcr.STTwelveWords + case 24: + seed, err = mnemonic.DecodeMnemonic(cfg.Mnemonic) + birthday = time.Unix(cfg.Birthday, 0) + seedType = dcr.STTwentyFourWords + default: + return "", fmt.Errorf("unknown mnemonic format. expected 12, 15, or 24 words, got %d", nWords) + } + if err != nil { + return "", fmt.Errorf("unable to decode wallet mnemonic: %v", err) + } + recoveryConfig = &dcr.RecoveryCfg{ + Seed: seed, + SeedPass: []byte(cfg.SeedPass), + SeedType: seedType, + Birthday: birthday, + } + } + if cfg.UseLocalSeed { + recoveryConfig = &dcr.RecoveryCfg{ + UseLocalSeed: true, + } + } + + walletCtx, cancel := context.WithCancel(mainCtx) + + w, err := dcr.CreateWallet(walletCtx, params, recoveryConfig) + if err != nil { + cancel() + return "", err + } + + wallets[name] = &wallet{ + Wallet: w, + log: logger, + ctx: walletCtx, + cancelCtx: cancel, + allowUnsyncedAddrs: cfg.AllowUnsyncedAddrs, + } + return "wallet created", nil +} + +// CreateWatchOnlyWallet creates a watch-only wallet with the given config JSON. +func CreateWatchOnlyWallet(configJSON string) (string, error) { + walletsMtx.Lock() + defer walletsMtx.Unlock() + if !initialized { + return "", errors.New("libwallet is not initialized") + } + + var cfg Config + if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { + return "", fmt.Errorf("malformed config: %v", err) + } + + name := cfg.Name + if _, exists := wallets[name]; exists { + return "", fmt.Errorf("wallet already exists with name: %q", name) + } + + logger := logBackend.SubLogger(name) + params := dcr.CreateWalletParams{ + OpenWalletParams: dcr.OpenWalletParams{ + Net: cfg.Net, + DataDir: cfg.DataDir, + DbDriver: "bdb", + Logger: logger, + }, + } + + walletCtx, cancel := context.WithCancel(mainCtx) + + w, err := dcr.CreateWatchOnlyWallet(walletCtx, cfg.PubKey, params, cfg.UseLocalSeed) + if err != nil { + cancel() + return "", err + } + + wallets[name] = &wallet{ + Wallet: w, + log: logger, + ctx: walletCtx, + cancelCtx: cancel, + allowUnsyncedAddrs: cfg.AllowUnsyncedAddrs, + } + return "wallet created", nil +} + +// LoadWallet loads an existing wallet. +func LoadWallet(configJSON string) (string, error) { + walletsMtx.Lock() + defer walletsMtx.Unlock() + if !initialized { + return "", errors.New("libwallet is not initialized") + } + + var cfg Config + if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { + return "", fmt.Errorf("malformed config: %v", err) + } + + name := cfg.Name + if _, exists := wallets[name]; exists { + return "wallet already loaded", nil // not an error, already loaded + } + + logger := logBackend.SubLogger(name) + params := dcr.OpenWalletParams{ + Net: cfg.Net, + DataDir: cfg.DataDir, + DbDriver: "bdb", // use badgerdb for mobile! + Logger: logger, + } + + walletCtx, cancel := context.WithCancel(mainCtx) + + w, err := dcr.LoadWallet(walletCtx, params) + if err != nil { + cancel() + return "", err + } + + if err = w.OpenWallet(walletCtx); err != nil { + cancel() + return "", err + } + + wallets[name] = &wallet{ + Wallet: w, + log: logger, + ctx: walletCtx, + cancelCtx: cancel, + allowUnsyncedAddrs: cfg.AllowUnsyncedAddrs, + } + return fmt.Sprintf("wallet %q loaded", name), nil +} + +// CloseWallet closes a wallet. +func CloseWallet(name string) (string, error) { + walletsMtx.Lock() + defer walletsMtx.Unlock() + + w, exists := wallets[name] + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + w.cancelCtx() + w.Wait() + if err := w.CloseWallet(); err != nil { + return "", fmt.Errorf("close wallet %q error: %v", name, err) + } + delete(wallets, name) + return fmt.Sprintf("wallet %q shutdown", name), nil +} + +// WalletSeed returns the wallet seed. +func WalletSeed(name, pass string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q not loaded", name) + } + + seed, err := w.DecryptSeed([]byte(pass)) + if err != nil { + return "", fmt.Errorf("w.DecryptSeed error: %v", err) + } + + return seed, nil +} + +// WalletBalance returns the wallet balance as JSON. +func WalletBalance(name string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q not loaded", name) + } + + const confs = 1 + bals, err := w.AccountBalances(w.ctx, confs) + if err != nil { + return "", fmt.Errorf("w.AccountBalances error: %v", err) + } + + balMap := map[string]int64{ + "confirmed": 0, + "unconfirmed": 0, + } + + for _, bal := range bals { + balMap["confirmed"] += int64(bal.Spendable) + balMap["unconfirmed"] += int64(bal.Total) - int64(bal.Spendable) + } + + balJSON, err := json.Marshal(balMap) + if err != nil { + return "", fmt.Errorf("marshal balMap error: %v", err) + } + + return string(balJSON), nil +} + +// ChangePassphrase changes the wallet passphrase. +func ChangePassphrase(name, oldPass, newPass string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q not loaded", name) + } + + oldPassBytes, newPassBytes := []byte(oldPass), []byte(newPass) + if err := w.MainWallet().ChangePrivatePassphrase(w.ctx, oldPassBytes, newPassBytes); err != nil { + return "", fmt.Errorf("w.ChangePrivatePassphrase error: %v", err) + } + + if err := w.ReEncryptSeed(oldPassBytes, newPassBytes); err != nil { + // Undo the passphrase change, since the re-encrypting the seed failed. + if undoErr := w.MainWallet().ChangePrivatePassphrase(w.ctx, newPassBytes, oldPassBytes); undoErr != nil { + logMtx.RLock() + log.Errorf("error undoing passphrase change: %v", undoErr) + logMtx.RUnlock() + } + return "", fmt.Errorf("w.ReEncryptSeed error: %v", err) + } + + return "passphrase changed", nil +} + +// ----------------------------------------------------------------------------- +// Address Functions +// ----------------------------------------------------------------------------- + +// CurrentReceiveAddress returns the current receive address. +func CurrentReceiveAddress(name string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q is not loaded", name) + } + + if !w.allowUnsyncedAddrs { + synced, _ := w.IsSynced(w.ctx) + if !synced { + return "", fmt.Errorf("currentReceiveAddress requested on an unsynced wallet (error code: %d)", ErrCodeNotSynced) + } + } + + addr, err := w.CurrentAddress(udb.DefaultAccountNum) + if err != nil { + return "", fmt.Errorf("w.CurrentAddress error: %v", err) + } + + return addr.String(), nil +} + +// NewExternalAddress creates a new external address. +func NewExternalAddress(name string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q is not loaded", name) + } + + if !w.allowUnsyncedAddrs { + synced, _ := w.IsSynced(w.ctx) + if !synced { + return "", fmt.Errorf("newExternalAddress requested on an unsynced wallet (error code: %d)", ErrCodeNotSynced) + } + } + + _, err := w.NewExternalAddress(w.ctx, udb.DefaultAccountNum) + if err != nil { + return "", fmt.Errorf("w.NewExternalAddress error: %v", err) + } + + // NewExternalAddress will take the current address before increasing + // the index. Get the current address after increasing the index. + addr, err := w.CurrentAddress(udb.DefaultAccountNum) + if err != nil { + return "", fmt.Errorf("w.CurrentAddress error: %v", err) + } + + return addr.String(), nil +} + +// SignMessage signs a message with the private key of the address. +func SignMessage(name, message, address, password string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q is not loaded", name) + } + + addr, err := stdaddr.DecodeAddress(address, w.MainWallet().ChainParams()) + if err != nil { + return "", fmt.Errorf("unable to decode address: %v", err) + } + + // Addresses must have an associated secp256k1 private key and therefore + // must be P2PK or P2PKH (P2SH is not allowed). + switch addr.(type) { + case *stdaddr.AddressPubKeyEcdsaSecp256k1V0: + case *stdaddr.AddressPubKeyHashEcdsaSecp256k1V0: + // Valid address types, proceed to sign. + default: + return "", errors.New("invalid address type: must be P2PK or P2PKH") + } + + if err := w.MainWallet().Unlock(w.ctx, []byte(password), nil); err != nil { + return "", fmt.Errorf("cannot unlock wallet: %v", err) + } + + sig, err := w.MainWallet().SignMessage(w.ctx, message, addr) + if err != nil { + return "", fmt.Errorf("unable to sign message: %v", err) + } + + sEnc := base64.StdEncoding.EncodeToString(sig) + return sEnc, nil +} + +// VerifyMessage verifies a signed message. +func VerifyMessage(name, message, address, sig string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q is not loaded", name) + } + + addr, err := stdaddr.DecodeAddress(address, w.MainWallet().ChainParams()) + if err != nil { + return "", fmt.Errorf("unable to decode address: %v", err) + } + + // Addresses must have an associated secp256k1 private key and therefore + // must be P2PK or P2PKH (P2SH is not allowed). + switch addr.(type) { + case *stdaddr.AddressPubKeyEcdsaSecp256k1V0: + case *stdaddr.AddressPubKeyHashEcdsaSecp256k1V0: + // Valid address types, proceed with verification. + default: + return "", errors.New("invalid address type: must be P2PK or P2PKH") + } + + sigBytes, err := base64.StdEncoding.DecodeString(sig) + if err != nil { + return "", fmt.Errorf("unable to decode signature: %v", err) + } + + ok, err = dcrwallet.VerifyMessage(message, addr, sigBytes, w.MainWallet().ChainParams()) + if err != nil { + return "", fmt.Errorf("unable to verify message: %v", err) + } + + return fmt.Sprintf("%v", ok), nil +} + +// Addresses returns the used and unused addresses. +func Addresses(name, nUsed, nUnused string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q is not loaded", name) + } + + nUsedVal, err := strconv.ParseUint(nUsed, 10, 32) + if err != nil { + return "", fmt.Errorf("number of used addresses is not a uint32: %v", err) + } + + nUnusedVal, err := strconv.ParseUint(nUnused, 10, 32) + if err != nil { + return "", fmt.Errorf("number of unused addresses is not a uint32: %v", err) + } + + used, unused, index, err := w.DefaultAccountAddresses(w.ctx, uint32(nUsedVal), uint32(nUnusedVal)) + if err != nil { + return "", fmt.Errorf("w.DefaultAccountAddresses error: %v", err) + } + + res := &AddressesRes{ + Used: used, + Unused: []string{}, + Index: index, + } + synced, _ := w.IsSynced(w.ctx) + if synced || w.allowUnsyncedAddrs { + res.Unused = unused + } + + b, err := json.Marshal(res) + if err != nil { + return "", fmt.Errorf("unable to marshal addresses: %v", err) + } + + return string(b), nil +} + +// DefaultPubkey returns the default account public key. +func DefaultPubkey(name string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q is not loaded", name) + } + + pubkey, err := w.AccountPubkey(w.ctx, defaultAccount) + if err != nil { + return "", fmt.Errorf("unable to get default pubkey: %v", err) + } + + return pubkey, nil +} + +// ValidateAddr validates an address. +func ValidateAddr(name, addr string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + validated, err := w.ValidateAddr(w.ctx, addr) + if err != nil { + return "", fmt.Errorf("unable to validate address: %v", err) + } + b, err := json.Marshal(validated) + if err != nil { + return "", fmt.Errorf("unable to marshal validate address: %v", err) + } + return string(b), nil +} + +// AddrFromExtendedKey returns an address from an extended key. +func AddrFromExtendedKey(addrFromExtKeyJSON string) (string, error) { + var fromExt AddrFromExtKey + if err := json.Unmarshal([]byte(addrFromExtKeyJSON), &fromExt); err != nil { + return "", fmt.Errorf("malformed create addr json: %v", err) + } + addr, err := dcr.AddrFromExtendedKey(fromExt.Key, fromExt.Path, fromExt.AddrType, fromExt.UseChildBIP32Std) + if err != nil { + return "", fmt.Errorf("unable to create address: %v", err) + } + return addr, nil +} + +// CreateExtendedKey creates an extended key. +func CreateExtendedKey(createExtKeyJSON string) (string, error) { + var createExt CreateExtendedKeyReq + if err := json.Unmarshal([]byte(createExtKeyJSON), &createExt); err != nil { + return "", fmt.Errorf("malformed create extended key json: %v", err) + } + extKey, err := dcr.CreateExtendedKey(createExt.Key, createExt.ParentKey, createExt.ChainCode, + createExt.Network, uint8(createExt.Depth), uint32(createExt.ChildN), createExt.IsPrivate) + if err != nil { + return "", fmt.Errorf("unable to create key: %v", err) + } + return extKey, nil +} + +// ----------------------------------------------------------------------------- +// Sync Functions +// ----------------------------------------------------------------------------- + +// SyncWallet starts syncing the wallet with the given peers. +func SyncWallet(name, peers string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + var peerList []string + for _, p := range strings.Split(peers, ",") { + if p = strings.TrimSpace(p); p != "" { + peerList = append(peerList, p) + } + } + ntfns := &spv.Notifications{ + Synced: func(sync bool) { + w.syncStatusMtx.Lock() + w.syncStatusCode = SSCComplete + w.syncStatusMtx.Unlock() + w.log.Debug("Sync completed.") + }, + PeerConnected: func(peerCount int32, addr string) { + w.syncStatusMtx.Lock() + w.numPeers = int(peerCount) + w.syncStatusMtx.Unlock() + w.log.Debugf("Connected to peer at %s. %d total peers.", addr, peerCount) + }, + PeerDisconnected: func(peerCount int32, addr string) { + w.syncStatusMtx.Lock() + w.numPeers = int(peerCount) + w.syncStatusMtx.Unlock() + w.log.Debugf("Disconnected from peer at %s. %d total peers.", addr, peerCount) + }, + FetchMissingCFiltersStarted: func() { + w.syncStatusMtx.Lock() + if w.rescanning { + w.syncStatusMtx.Unlock() + return + } + w.syncStatusCode = SSCFetchingCFilters + w.syncStatusMtx.Unlock() + w.log.Debug("Fetching missing cfilters started.") + }, + FetchMissingCFiltersProgress: func(startCFiltersHeight, endCFiltersHeight int32) { + w.syncStatusMtx.Lock() + w.cfiltersHeight = int(endCFiltersHeight) + w.syncStatusMtx.Unlock() + w.log.Debugf("Fetching cfilters from %d to %d.", startCFiltersHeight, endCFiltersHeight) + }, + FetchMissingCFiltersFinished: func() { + w.syncStatusMtx.Lock() + w.cfiltersHeight = w.targetHeight + w.syncStatusMtx.Unlock() + w.log.Debug("Finished fetching missing cfilters.") + }, + FetchHeadersStarted: func() { + w.syncStatusMtx.Lock() + if w.rescanning { + w.syncStatusMtx.Unlock() + return + } + w.syncStatusCode = SSCFetchingHeaders + w.syncStatusMtx.Unlock() + w.log.Debug("Fetching headers started.") + }, + FetchHeadersProgress: func(lastHeaderHeight int32, lastHeaderTime int64) { + w.syncStatusMtx.Lock() + w.headersHeight = int(lastHeaderHeight) + w.syncStatusMtx.Unlock() + w.log.Debugf("Fetching headers to %d.", lastHeaderHeight) + }, + FetchHeadersFinished: func() { + w.syncStatusMtx.Lock() + w.headersHeight = w.targetHeight + w.syncStatusMtx.Unlock() + w.log.Debug("Fetching headers finished.") + }, + DiscoverAddressesStarted: func() { + w.syncStatusMtx.Lock() + if w.rescanning { + w.syncStatusMtx.Unlock() + return + } + w.syncStatusCode = SSCDiscoveringAddrs + w.syncStatusMtx.Unlock() + w.log.Debug("Discover addresses started.") + }, + DiscoverAddressesFinished: func() { + w.log.Debug("Discover addresses finished.") + }, + RescanStarted: func() { + w.syncStatusMtx.Lock() + if w.rescanning { + w.syncStatusMtx.Unlock() + return + } + w.syncStatusCode = SSCRescanning + w.syncStatusMtx.Unlock() + w.log.Debug("Rescan started.") + }, + RescanProgress: func(rescannedThrough int32) { + w.syncStatusMtx.Lock() + w.rescanHeight = int(rescannedThrough) + w.syncStatusMtx.Unlock() + w.log.Debugf("Rescanned through block %d.", rescannedThrough) + }, + RescanFinished: func() { + w.syncStatusMtx.Lock() + w.rescanHeight = w.targetHeight + w.syncStatusMtx.Unlock() + w.log.Debug("Rescan finished.") + }, + } + if err := w.StartSync(w.ctx, ntfns, peerList...); err != nil { + return "", err + } + return "sync started", nil +} + +// SyncWalletStatus returns the sync status of the wallet. +func SyncWalletStatus(name string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + + w.syncStatusMtx.RLock() + var ssc, cfh, hh, rh, np = w.syncStatusCode, w.cfiltersHeight, w.headersHeight, w.rescanHeight, w.numPeers + w.syncStatusMtx.RUnlock() + + // Sometimes it appears we miss a notification during start up. This is + // a bandaid to put us as synced in that case. + synced, targetHeight := w.IsSynced(w.ctx) + w.syncStatusMtx.Lock() + if ssc != SSCComplete && synced && !w.rescanning { + ssc = SSCComplete + w.syncStatusCode = ssc + } + w.syncStatusMtx.Unlock() + + ss := &SyncStatusRes{ + SyncStatusCode: int(ssc), + SyncStatus: ssc.String(), + TargetHeight: int(targetHeight), + NumPeers: np, + } + switch ssc { + case SSCFetchingCFilters: + ss.CFiltersHeight = cfh + case SSCFetchingHeaders: + ss.HeadersHeight = hh + case SSCRescanning: + ss.RescanHeight = rh + } + b, err := json.Marshal(ss) + if err != nil { + return "", fmt.Errorf("unable to marshal sync status result: %v", err) + } + return string(b), nil +} + +// RescanFromHeight starts a rescan from the given height. +func RescanFromHeight(name, height string) (string, error) { + heightVal, err := strconv.ParseUint(height, 10, 32) + if err != nil { + return "", fmt.Errorf("height is not an uint32: %v", err) + } + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + synced, _ := w.IsSynced(w.ctx) + if !synced { + return "", fmt.Errorf("rescanFromHeight requested on an unsynced wallet (error code: %d)", ErrCodeNotSynced) + } + w.syncStatusMtx.Lock() + if w.rescanning { + w.syncStatusMtx.Unlock() + return "", fmt.Errorf("wallet %q already rescanning", name) + } + w.syncStatusCode = SSCRescanning + w.rescanning = true + w.rescanHeight = int(heightVal) + w.syncStatusMtx.Unlock() + w.Add(1) + go func() { + defer func() { + w.syncStatusMtx.Lock() + w.syncStatusCode = SSCComplete + w.rescanning = false + w.syncStatusMtx.Unlock() + w.Done() + }() + prog := make(chan dcrwallet.RescanProgress) + go func() { + w.RescanProgressFromHeight(w.ctx, int32(heightVal), prog) + }() + for { + select { + case p, open := <-prog: + if !open { + return + } + if p.Err != nil { + logMtx.RLock() + log.Errorf("rescan wallet %q error: %v", name, p.Err) + logMtx.RUnlock() + return + } + w.syncStatusMtx.Lock() + w.rescanHeight = int(p.ScannedThrough) + w.syncStatusMtx.Unlock() + case <-w.ctx.Done(): + return + } + } + }() + return fmt.Sprintf("rescan from height %d for wallet %q started", heightVal, name), nil +} + +// BirthState returns the birthday state of the wallet. +func BirthState(name string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q is not loaded", name) + } + + bs, err := w.MainWallet().BirthState(w.ctx) + if err != nil { + return "", fmt.Errorf("wallet.BirthState error: %v", err) + } + if bs == nil { + return "", fmt.Errorf("birth state is nil for wallet %q", name) + } + + bsRes := &BirthdayStateRes{ + Hash: bs.Hash.String(), + Height: bs.Height, + Time: bs.Time.Unix(), + SetFromHeight: bs.SetFromHeight, + SetFromTime: bs.SetFromTime, + } + b, err := json.Marshal(bsRes) + if err != nil { + return "", fmt.Errorf("unable to marshal birth state result: %v", err) + } + return string(b), nil +} + +// ----------------------------------------------------------------------------- +// Transaction Functions +// ----------------------------------------------------------------------------- + +// CreateTransaction creates a transaction. +func CreateTransaction(name, createTxReqJSON string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + var req CreateTxReq + if err := json.Unmarshal([]byte(createTxReqJSON), &req); err != nil { + return "", fmt.Errorf("malformed sign send request: %v", err) + } + + outputs := make([]*dcr.Output, len(req.Outputs)) + for i, out := range req.Outputs { + o := &dcr.Output{ + Address: out.Address, + Amount: uint64(out.Amount), + } + outputs[i] = o + } + + inputs := make([]*dcr.Input, len(req.Inputs)) + for i, in := range req.Inputs { + o := &dcr.Input{ + TxID: in.TxID, + Vout: uint32(in.Vout), + } + inputs[i] = o + } + + ignoreInputs := make([]*dcr.Input, len(req.IgnoreInputs)) + for i, in := range req.IgnoreInputs { + o := &dcr.Input{ + TxID: in.TxID, + Vout: uint32(in.Vout), + } + ignoreInputs[i] = o + } + + if req.Sign { + if err := w.MainWallet().Unlock(w.ctx, []byte(req.Password), nil); err != nil { + return "", fmt.Errorf("cannot unlock wallet: %v", err) + } + defer w.MainWallet().Lock() + } + + txBytes, txhash, fee, err := w.CreateTransaction(w.ctx, outputs, inputs, ignoreInputs, uint64(req.FeeRate), req.SendAll, req.Sign) + if err != nil { + return "", fmt.Errorf("unable to sign send transaction: %v", err) + } + res := &CreateTxRes{ + Hex: hex.EncodeToString(txBytes), + Txid: txhash.String(), + Fee: int(fee), + } + + b, err := json.Marshal(res) + if err != nil { + return "", fmt.Errorf("unable to marshal sign send transaction result: %v", err) + } + return string(b), nil +} + +// SendRawTransaction sends a raw transaction. +func SendRawTransaction(name, txHex string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + txHash, err := w.SendRawTransaction(w.ctx, txHex) + if err != nil { + return "", fmt.Errorf("unable to send raw transaction: %v", err) + } + return txHash.String(), nil +} + +// ListUnspents returns the unspent outputs. +func ListUnspents(name string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + res, err := w.MainWallet().ListUnspent(w.ctx, 1, math.MaxInt32, nil, defaultAccount) + if err != nil { + return "", fmt.Errorf("unable to get unspents: %v", err) + } + + type ListUnspentRes struct { + TxID string `json:"txid"` + Vout uint32 `json:"vout"` + Tree int8 `json:"tree"` + TxType int `json:"txtype"` + Address string `json:"address"` + Account string `json:"account"` + ScriptPubKey string `json:"scriptPubKey"` + RedeemScript string `json:"redeemScript,omitempty"` + Amount float64 `json:"amount"` + Confirmations int64 `json:"confirmations"` + Spendable bool `json:"spendable"` + IsChange bool `json:"ischange"` + } + + // Add is change to results. + unspentRes := make([]ListUnspentRes, len(res)) + for i, unspent := range res { + addr, err := stdaddr.DecodeAddress(unspent.Address, w.MainWallet().ChainParams()) + if err != nil { + return "", fmt.Errorf("unable to decode address: %v", err) + } + + ka, err := w.MainWallet().KnownAddress(w.ctx, addr) + if err != nil { + return "", fmt.Errorf("unspent address is not known: %v", err) + } + + isChange := false + if ka, ok := ka.(dcrwallet.BIP0044Address); ok { + _, branch, _ := ka.Path() + isChange = branch == 1 + } + unspentRes[i] = ListUnspentRes{ + TxID: unspent.TxID, + Vout: unspent.Vout, + Tree: unspent.Tree, + TxType: unspent.TxType, + Address: unspent.Address, + Account: unspent.Account, + ScriptPubKey: unspent.ScriptPubKey, + RedeemScript: unspent.RedeemScript, + Amount: unspent.Amount, + Confirmations: unspent.Confirmations, + Spendable: unspent.Spendable, + IsChange: isChange, + } + } + b, err := json.Marshal(unspentRes) + if err != nil { + return "", fmt.Errorf("unable to marshal list unspents result: %v", err) + } + return string(b), nil +} + +// EstimateFee estimates the fee for a transaction. +func EstimateFee(name, nBlocks string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + nBlocksVal, err := strconv.ParseUint(nBlocks, 10, 64) + if err != nil { + return "", fmt.Errorf("number of blocks is not a uint64: %v", err) + } + txFee, err := w.FetchFeeFromOracle(w.ctx, nBlocksVal) + if err != nil { + return "", fmt.Errorf("unable to get fee from oracle: %v", err) + } + return fmt.Sprintf("%d", uint64(txFee*1e8)), nil +} + +// ListTransactions lists transactions. +func ListTransactions(name, from, count string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + fromVal, err := strconv.ParseInt(from, 10, 32) + if err != nil { + return "", fmt.Errorf("from is not an int: %v", err) + } + countVal, err := strconv.ParseInt(count, 10, 32) + if err != nil { + return "", fmt.Errorf("count is not an int: %v", err) + } + res, err := w.MainWallet().ListTransactions(w.ctx, int(fromVal), int(countVal)) + if err != nil { + return "", fmt.Errorf("unable to get transactions: %v", err) + } + _, blockHeight := w.MainWallet().MainChainTip(w.ctx) + ltRes := make([]*ListTransactionRes, len(res)) + for i, ltw := range res { + // Use earliest of receive time or block time if the transaction is mined. + receiveTime := ltw.TimeReceived + if ltw.BlockTime != 0 && ltw.BlockTime < ltw.TimeReceived { + receiveTime = ltw.BlockTime + } + + var height int64 + if ltw.Confirmations > 0 { + height = int64(blockHeight) - ltw.Confirmations + 1 + } + + lt := &ListTransactionRes{ + Address: ltw.Address, + Amount: ltw.Amount, + Category: ltw.Category, + Confirmations: ltw.Confirmations, + Height: height, + Fee: ltw.Fee, + Time: receiveTime, + TxID: ltw.TxID, + Vout: ltw.Vout, + } + ltRes[i] = lt + } + b, err := json.Marshal(ltRes) + if err != nil { + return "", fmt.Errorf("unable to marshal list transactions result: %v", err) + } + return string(b), nil +} + +// BestBlock returns the best block. +func BestBlock(name string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + blockHash, blockHeight := w.MainWallet().MainChainTip(w.ctx) + res := &BestBlockRes{ + Hash: blockHash.String(), + Height: int(blockHeight), + } + b, err := json.Marshal(res) + if err != nil { + return "", fmt.Errorf("unable to marshal best block result: %v", err) + } + return string(b), nil +} + +// DecodeTx decodes a transaction. +func DecodeTx(name, txHex string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + decoded, err := w.DecodeTx(txHex) + if err != nil { + return "", fmt.Errorf("unable to decode tx: %v", err) + } + b, err := json.Marshal(decoded) + if err != nil { + return "", fmt.Errorf("unable to marshal decoded tx: %v", err) + } + return string(b), nil +} + +// GetTxn gets transactions by hashes. +func GetTxn(name, hashesJSON string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + var txIDs []string + if err := json.Unmarshal([]byte(hashesJSON), &txIDs); err != nil { + return "", fmt.Errorf("unable to unmarshal hashes: %v", err) + } + txHashes := make([]*chainhash.Hash, len(txIDs)) + for i, txID := range txIDs { + txHash, err := chainhash.NewHashFromStr(txID) + if err != nil { + return "", fmt.Errorf("unable to create tx hash: %v", err) + } + txHashes[i] = txHash + } + hexes, err := w.GetTxn(w.ctx, txHashes) + if err != nil { + return "", fmt.Errorf("unable to get txn: %v", err) + } + b, err := json.Marshal(hexes) + if err != nil { + return "", fmt.Errorf("unable to marshal txn: %v", err) + } + return string(b), nil +} + +// AddSigs adds signatures to a transaction. +func AddSigs(name, txHex, sigScriptsJSON string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + var sigScripts []string + if err := json.Unmarshal([]byte(sigScriptsJSON), &sigScripts); err != nil { + return "", fmt.Errorf("unable to unmarshal sig scripts: %v", err) + } + signedHex, err := w.AddSigs(txHex, sigScripts) + if err != nil { + return "", fmt.Errorf("unable sign tx: %v", err) + } + return signedHex, nil +} diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..89d5150 --- /dev/null +++ b/tools.go @@ -0,0 +1,9 @@ +//go:build tools +// +build tools + +package tools + +import ( + _ "golang.org/x/mobile/cmd/gobind" + _ "golang.org/x/mobile/cmd/gomobile" +) From f945f107858283c5a770d0e0ff0b0b757312e3fa Mon Sep 17 00:00:00 2001 From: Jusitn Do Date: Sat, 20 Dec 2025 13:15:23 +0700 Subject: [PATCH 2/6] add build script for ios --- build_gomobile_ios.sh | 800 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 800 insertions(+) create mode 100755 build_gomobile_ios.sh diff --git a/build_gomobile_ios.sh b/build_gomobile_ios.sh new file mode 100755 index 0000000..2b27779 --- /dev/null +++ b/build_gomobile_ios.sh @@ -0,0 +1,800 @@ +#!/bin/bash +################################################################################ +# Gomobile iOS Build Script for libwallet +# +# This script builds the libwallet library for iOS using gomobile, creating +# an XCFramework that can be integrated into iOS applications. +# +# Usage: +# ./build_gomobile_ios.sh [command] [options] +# +# Commands: +# (no args) Build for both device and simulator (default) +# device Build for iOS device only (arm64) +# simulator Build for iOS simulator only (arm64 + x86_64) +# all Build for both device and simulator (explicit) +# clean Clean build directory +# zip Build and create zip archive +# verify Verify existing build +# info Show build environment information +# help Show this help message +# +# Options: +# -v, --verbose Show verbose build output +# --no-optimize Skip optimization flags +# --backup Backup existing framework before build +# --skip-install Skip auto-install of gomobile +# +# Examples: +# ./build_gomobile_ios.sh # Build everything +# ./build_gomobile_ios.sh device --verbose # Build device with verbose output +# ./build_gomobile_ios.sh zip # Build and create zip +# +# Author: Generated for libwallet project +# Version: 1.0.0 +################################################################################ + +set -e # Exit on error +set -o pipefail # Pipe failures propagate + +################################################################################ +# CONSTANTS & CONFIGURATION +################################################################################ + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$SCRIPT_DIR" +BUILD_DIR="$PROJECT_DIR/build" +MOBILE_DIR="$PROJECT_DIR/mobile" + +FRAMEWORK_NAME="Libwallet" +XCFRAMEWORK_NAME="${FRAMEWORK_NAME}.xcframework" +MIN_GO_VERSION="1.21" +MIN_IOS_VERSION="13.0" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +# Flags +VERBOSE=false +OPTIMIZE=true +BACKUP=false +SKIP_INSTALL=false + +# Timing +BUILD_START_TIME=0 + +################################################################################ +# HELPER FUNCTIONS +################################################################################ + +# Logging functions +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_step() { + echo -e "${CYAN}[STEP]${NC} $1" +} + +# Print banner +print_banner() { + echo -e "${BOLD}${BLUE}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " Gomobile iOS Build Script - Libwallet" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo -e "${NC}" +} + +# Print separator +print_separator() { + echo -e "${BLUE}──────────────────────────────────────────────────────────────────────${NC}" +} + +# Format duration (seconds to human readable) +format_duration() { + local seconds=$1 + if [ $seconds -lt 60 ]; then + echo "${seconds}s" + else + local minutes=$((seconds / 60)) + local secs=$((seconds % 60)) + echo "${minutes}m ${secs}s" + fi +} + +# Format file size +format_size() { + local path=$1 + if [ -d "$path" ]; then + du -sh "$path" 2>/dev/null | cut -f1 || echo "unknown" + elif [ -f "$path" ]; then + du -sh "$path" 2>/dev/null | cut -f1 || echo "unknown" + else + echo "unknown" + fi +} + +# Start timer +start_timer() { + BUILD_START_TIME=$(date +%s) +} + +# Get elapsed time +get_elapsed_time() { + local end_time=$(date +%s) + echo $((end_time - BUILD_START_TIME)) +} + +################################################################################ +# VERIFICATION FUNCTIONS +################################################################################ + +# Check Go installation and version +check_go_version() { + log_step "Checking Go installation..." + + if ! command -v go &> /dev/null; then + log_error "Go is not installed" + echo "Please install Go from https://golang.org/dl/" + exit 1 + fi + + local go_version=$(go version | awk '{print $3}' | sed 's/go//') + log_info "✓ Go $go_version" + + # Simple version comparison (works for most cases) + local min_version_num=$(echo $MIN_GO_VERSION | tr -d '.') + local current_version_num=$(echo $go_version | cut -d. -f1,2 | tr -d '.') + + if [ "$current_version_num" -lt "$min_version_num" ]; then + log_warn "Go version $go_version is older than recommended $MIN_GO_VERSION" + fi +} + +# Check Xcode and iOS SDK +check_xcode() { + log_step "Checking Xcode..." + + if ! command -v xcrun &> /dev/null; then + log_error "Xcode Command Line Tools not installed" + echo "Install with: xcode-select --install" + exit 1 + fi + + local xcode_version=$(xcodebuild -version 2>/dev/null | head -1 || echo "Unknown") + log_info "✓ $xcode_version" + + local ios_sdk_version=$(xcrun --sdk iphoneos --show-sdk-version 2>/dev/null || echo "Unknown") + log_info "✓ iOS SDK $ios_sdk_version" +} + +# Check gomobile installation +check_gomobile() { + log_step "Checking gomobile..." + + if command -v gomobile &> /dev/null; then + local gomobile_path=$(which gomobile) + log_info "✓ gomobile installed at $gomobile_path" + return 0 + else + log_warn "gomobile not found" + return 1 + fi +} + +# Verify mobile package exists +verify_mobile_package() { + log_step "Verifying mobile package..." + + if [ ! -d "$MOBILE_DIR" ]; then + log_error "Mobile package directory not found: $MOBILE_DIR" + exit 1 + fi + + if [ ! -f "$MOBILE_DIR/mobile.go" ]; then + log_error "mobile.go not found in $MOBILE_DIR" + exit 1 + fi + + log_info "✓ Mobile package found at $MOBILE_DIR" +} + +# Validate XCFramework structure +validate_xcframework() { + local xcframework_path="$BUILD_DIR/$XCFRAMEWORK_NAME" + + log_step "Validating XCFramework..." + + if [ ! -d "$xcframework_path" ]; then + log_error "XCFramework not found at $xcframework_path" + return 1 + fi + + # Check Info.plist + if [ ! -f "$xcframework_path/Info.plist" ]; then + log_error "Info.plist not found in XCFramework" + return 1 + fi + + # Check for expected directories + local has_device=false + local has_simulator=false + + if [ -d "$xcframework_path/ios-arm64" ]; then + has_device=true + log_info "✓ Device framework found (arm64)" + fi + + if [ -d "$xcframework_path/ios-arm64_x86_64-simulator" ]; then + has_simulator=true + log_info "✓ Simulator framework found (arm64 + x86_64)" + fi + + if [ "$has_device" = false ] && [ "$has_simulator" = false ]; then + log_error "No valid frameworks found in XCFramework" + return 1 + fi + + local size=$(format_size "$xcframework_path") + log_success "XCFramework validation passed ($size)" + return 0 +} + +################################################################################ +# INSTALLATION FUNCTIONS +################################################################################ + +# Install gomobile and gobind +install_gomobile() { + if [ "$SKIP_INSTALL" = true ]; then + log_warn "Skipping gomobile installation (--skip-install)" + return 1 + fi + + log_step "Installing gomobile and gobind..." + + # Install gomobile + if ! go install golang.org/x/mobile/cmd/gomobile@latest; then + log_error "Failed to install gomobile" + return 1 + fi + + # Install gobind + if ! go install golang.org/x/mobile/cmd/gobind@latest; then + log_error "Failed to install gobind" + return 1 + fi + + log_success "gomobile and gobind installed successfully" + return 0 +} + +# Initialize gomobile +init_gomobile() { + log_step "Initializing gomobile..." + + if ! gomobile init; then + log_error "Failed to initialize gomobile" + return 1 + fi + + log_success "gomobile initialized" + return 0 +} + +################################################################################ +# BUILD FUNCTIONS +################################################################################ + +# Build for iOS device only +build_device() { + log_step "Building for iOS device (arm64)..." + + local target="ios/arm64" + local ldflags="" + local verbose_flag="" + + if [ "$OPTIMIZE" = true ]; then + ldflags='-ldflags="-s -w"' + fi + + if [ "$VERBOSE" = true ]; then + verbose_flag="-v" + fi + + cd "$PROJECT_DIR" + + log_info "Running: gomobile bind -target=$target $ldflags $verbose_flag -o $BUILD_DIR/$XCFRAMEWORK_NAME ./mobile" + + if [ "$OPTIMIZE" = true ]; then + if [ "$VERBOSE" = true ]; then + gomobile bind -target="$target" -ldflags="-s -w" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + else + gomobile bind -target="$target" -ldflags="-s -w" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + fi + else + if [ "$VERBOSE" = true ]; then + gomobile bind -target="$target" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + else + gomobile bind -target="$target" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + fi + fi + + if [ $? -ne 0 ]; then + log_error "Device build failed" + return 1 + fi + + log_success "Device build completed" + return 0 +} + +# Build for iOS simulator only +build_simulator() { + log_step "Building for iOS simulator (arm64 + x86_64)..." + + local target="iossimulator" + local ldflags="" + local verbose_flag="" + + if [ "$OPTIMIZE" = true ]; then + ldflags='-ldflags="-s -w"' + fi + + if [ "$VERBOSE" = true ]; then + verbose_flag="-v" + fi + + cd "$PROJECT_DIR" + + log_info "Running: gomobile bind -target=$target $ldflags $verbose_flag -o $BUILD_DIR/$XCFRAMEWORK_NAME ./mobile" + + if [ "$OPTIMIZE" = true ]; then + if [ "$VERBOSE" = true ]; then + gomobile bind -target="$target" -ldflags="-s -w" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + else + gomobile bind -target="$target" -ldflags="-s -w" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + fi + else + if [ "$VERBOSE" = true ]; then + gomobile bind -target="$target" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + else + gomobile bind -target="$target" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + fi + fi + + if [ $? -ne 0 ]; then + log_error "Simulator build failed" + return 1 + fi + + log_success "Simulator build completed" + return 0 +} + +# Build for both device and simulator +build_all() { + log_step "Building for iOS device and simulator..." + + local target="ios" + local ldflags="" + local verbose_flag="" + + if [ "$OPTIMIZE" = true ]; then + ldflags='-ldflags="-s -w"' + fi + + if [ "$VERBOSE" = true ]; then + verbose_flag="-v" + fi + + cd "$PROJECT_DIR" + + log_info "Running: gomobile bind -target=$target $ldflags $verbose_flag -o $BUILD_DIR/$XCFRAMEWORK_NAME ./mobile" + + if [ "$OPTIMIZE" = true ]; then + if [ "$VERBOSE" = true ]; then + gomobile bind -target="$target" -ldflags="-s -w" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + else + gomobile bind -target="$target" -ldflags="-s -w" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + fi + else + if [ "$VERBOSE" = true ]; then + gomobile bind -target="$target" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + else + gomobile bind -target="$target" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + fi + fi + + if [ $? -ne 0 ]; then + log_error "Build failed" + return 1 + fi + + log_success "Build completed" + return 0 +} + +# Create zip archive +create_zip() { + local xcframework_path="$BUILD_DIR/$XCFRAMEWORK_NAME" + local zip_path="$BUILD_DIR/${XCFRAMEWORK_NAME}.zip" + + log_step "Creating zip archive..." + + if [ ! -d "$xcframework_path" ]; then + log_error "XCFramework not found. Build first." + return 1 + fi + + # Remove old zip if exists + rm -f "$zip_path" + + # Create zip + cd "$BUILD_DIR" + if ! zip -r -q "${XCFRAMEWORK_NAME}.zip" "$XCFRAMEWORK_NAME"; then + log_error "Failed to create zip archive" + return 1 + fi + + local size=$(format_size "$zip_path") + log_success "Zip archive created: ${XCFRAMEWORK_NAME}.zip ($size)" + return 0 +} + +################################################################################ +# UTILITY FUNCTIONS +################################################################################ + +# Clean build directory +clean_build() { + log_step "Cleaning build directory..." + + if [ -d "$BUILD_DIR" ]; then + rm -rf "$BUILD_DIR" + log_success "Build directory cleaned" + else + log_info "Build directory already clean" + fi +} + +# Backup existing framework +backup_existing() { + local xcframework_path="$BUILD_DIR/$XCFRAMEWORK_NAME" + + if [ ! -d "$xcframework_path" ]; then + return 0 + fi + + local timestamp=$(date +%Y%m%d_%H%M%S) + local backup_path="${xcframework_path}_backup_${timestamp}" + + log_step "Backing up existing framework..." + cp -R "$xcframework_path" "$backup_path" + log_success "Backup created: $(basename $backup_path)" +} + +# Generate build info JSON +generate_build_info() { + local xcframework_path="$BUILD_DIR/$XCFRAMEWORK_NAME" + local info_path="$BUILD_DIR/build-info.json" + + if [ ! -d "$xcframework_path" ]; then + return 0 + fi + + log_step "Generating build info..." + + local build_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + local elapsed=$(get_elapsed_time) + local go_version=$(go version | awk '{print $3}' | sed 's/go//') + local xcode_version=$(xcodebuild -version 2>/dev/null | head -1 | awk '{print $2}' || echo "Unknown") + local ios_sdk=$(xcrun --sdk iphoneos --show-sdk-version 2>/dev/null || echo "Unknown") + local size=$(format_size "$xcframework_path") + local git_commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + local git_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") + + cat > "$info_path" << EOF +{ + "framework_name": "$XCFRAMEWORK_NAME", + "build_date": "$build_date", + "build_duration": "${elapsed}s", + "go_version": "$go_version", + "xcode_version": "$xcode_version", + "ios_sdk_version": "$ios_sdk", + "gomobile_version": "latest", + "target": "ios", + "optimizations": $OPTIMIZE, + "framework_size": "$size", + "git_commit": "$git_commit", + "git_branch": "$git_branch" +} +EOF + + log_success "Build info generated: build-info.json" +} + +# Show build environment info +show_info() { + print_banner + echo "Build Environment Information:" + print_separator + + # Go + if command -v go &> /dev/null; then + echo -e "${GREEN}Go:${NC} $(go version)" + else + echo -e "${RED}Go:${NC} Not installed" + fi + + # Xcode + if command -v xcodebuild &> /dev/null; then + echo -e "${GREEN}Xcode:${NC} $(xcodebuild -version 2>/dev/null | head -1 || echo 'Unknown')" + local ios_sdk=$(xcrun --sdk iphoneos --show-sdk-version 2>/dev/null || echo "Unknown") + echo -e "${GREEN}iOS SDK:${NC} $ios_sdk" + else + echo -e "${RED}Xcode:${NC} Not installed" + fi + + # gomobile + if command -v gomobile &> /dev/null; then + echo -e "${GREEN}gomobile:${NC} $(which gomobile)" + else + echo -e "${YELLOW}gomobile:${NC} Not installed" + fi + + # Paths + print_separator + echo "Project Paths:" + echo -e "${CYAN}Project Dir:${NC} $PROJECT_DIR" + echo -e "${CYAN}Build Dir:${NC} $BUILD_DIR" + echo -e "${CYAN}Mobile Dir:${NC} $MOBILE_DIR" + + # Build artifacts + if [ -d "$BUILD_DIR/$XCFRAMEWORK_NAME" ]; then + print_separator + echo "Build Artifacts:" + local size=$(format_size "$BUILD_DIR/$XCFRAMEWORK_NAME") + echo -e "${CYAN}XCFramework:${NC} $XCFRAMEWORK_NAME ($size)" + + if [ -f "$BUILD_DIR/${XCFRAMEWORK_NAME}.zip" ]; then + local zip_size=$(format_size "$BUILD_DIR/${XCFRAMEWORK_NAME}.zip") + echo -e "${CYAN}Zip Archive:${NC} ${XCFRAMEWORK_NAME}.zip ($zip_size)" + fi + fi + + print_separator +} + +# Show help +show_help() { + cat << 'EOF' +Gomobile iOS Build Script for libwallet + +Usage: ./build_gomobile_ios.sh [command] [options] + +Commands: + (no args) Build for both device and simulator (default) + device Build for iOS device only (arm64) + simulator Build for iOS simulator only (arm64 + x86_64) + all Build for both device and simulator (explicit) + clean Clean build directory + zip Build and create zip archive + verify Verify existing build + info Show build environment information + help Show this help message + +Options: + -v, --verbose Show verbose build output + --no-optimize Skip optimization flags (-ldflags="-s -w") + --backup Backup existing framework before build + --skip-install Skip auto-install of gomobile + +Examples: + ./build_gomobile_ios.sh # Build everything + ./build_gomobile_ios.sh device --verbose # Build device with verbose output + ./build_gomobile_ios.sh zip # Build and create zip + ./build_gomobile_ios.sh clean # Clean build directory + ./build_gomobile_ios.sh info # Show environment info + +Output: + build/Libwallet.xcframework/ # XCFramework output + build/Libwallet.xcframework.zip # Zip archive (with 'zip' command) + build/build-info.json # Build metadata + +For more information, see jt-docs/BUILD_IOS_GOMOBILE.md +EOF +} + +################################################################################ +# MAIN SCRIPT +################################################################################ + +# Parse command line arguments +COMMAND="${1:-all}" +shift || true + +while [[ $# -gt 0 ]]; do + case $1 in + -v|--verbose) + VERBOSE=true + shift + ;; + --no-optimize) + OPTIMIZE=false + shift + ;; + --backup) + BACKUP=true + shift + ;; + --skip-install) + SKIP_INSTALL=true + shift + ;; + *) + log_error "Unknown option: $1" + echo "Run './build_gomobile_ios.sh help' for usage information" + exit 1 + ;; + esac +done + +# Main execution +main() { + # Handle help and info commands without banner + if [ "$COMMAND" = "help" ] || [ "$COMMAND" = "--help" ] || [ "$COMMAND" = "-h" ]; then + show_help + exit 0 + fi + + if [ "$COMMAND" = "info" ]; then + show_info + exit 0 + fi + + # Print banner + print_banner + + # Handle clean command + if [ "$COMMAND" = "clean" ]; then + clean_build + exit 0 + fi + + # Handle verify command + if [ "$COMMAND" = "verify" ]; then + if validate_xcframework; then + exit 0 + else + exit 1 + fi + fi + + # Start timer + start_timer + + # Run verifications + echo "Build Configuration:" + echo -e "${CYAN}Target:${NC} $COMMAND" + echo -e "${CYAN}Optimize:${NC} $OPTIMIZE" + echo -e "${CYAN}Verbose:${NC} $VERBOSE" + echo -e "${CYAN}Backup:${NC} $BACKUP" + print_separator + + check_go_version + check_xcode + verify_mobile_package + + # Check and install gomobile if needed + if ! check_gomobile; then + if install_gomobile; then + init_gomobile + else + log_error "gomobile installation failed and --skip-install was set" + exit 1 + fi + fi + + print_separator + + # Create build directory + mkdir -p "$BUILD_DIR" + + # Backup if requested + if [ "$BACKUP" = true ]; then + backup_existing + fi + + # Execute build command + local build_success=false + + case "$COMMAND" in + device) + if build_device; then + build_success=true + fi + ;; + simulator) + if build_simulator; then + build_success=true + fi + ;; + all|"") + if build_all; then + build_success=true + fi + ;; + zip) + if build_all; then + if create_zip; then + build_success=true + fi + fi + ;; + *) + log_error "Unknown command: $COMMAND" + echo "Run './build_gomobile_ios.sh help' for usage information" + exit 1 + ;; + esac + + if [ "$build_success" = false ]; then + log_error "Build failed" + exit 1 + fi + + print_separator + + # Validate build + validate_xcframework + + # Generate build info + generate_build_info + + # Print summary + print_separator + local elapsed=$(get_elapsed_time) + local duration=$(format_duration $elapsed) + local size=$(format_size "$BUILD_DIR/$XCFRAMEWORK_NAME") + + echo "" + log_success "Build completed successfully in $duration" + echo "" + echo "Output:" + echo -e " ${CYAN}Framework:${NC} $BUILD_DIR/$XCFRAMEWORK_NAME ($size)" + + if [ -f "$BUILD_DIR/${XCFRAMEWORK_NAME}.zip" ]; then + local zip_size=$(format_size "$BUILD_DIR/${XCFRAMEWORK_NAME}.zip") + echo -e " ${CYAN}Zip:${NC} $BUILD_DIR/${XCFRAMEWORK_NAME}.zip ($zip_size)" + fi + + if [ -f "$BUILD_DIR/build-info.json" ]; then + echo -e " ${CYAN}Build Info:${NC} $BUILD_DIR/build-info.json" + fi + + echo "" + print_separator +} + +# Run main function +main From 817800def97a69d22d9be3265116210486606058 Mon Sep 17 00:00:00 2001 From: dreacot Date: Tue, 23 Dec 2025 17:10:43 +0100 Subject: [PATCH 3/6] setup gomobile build for android --- build_gomobile_android.sh | 375 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100755 build_gomobile_android.sh diff --git a/build_gomobile_android.sh b/build_gomobile_android.sh new file mode 100755 index 0000000..03d5590 --- /dev/null +++ b/build_gomobile_android.sh @@ -0,0 +1,375 @@ +#!/bin/bash +################################################################################ +# Gomobile Android Build Script for libwallet +# +# Builds the libwallet library for Android using gomobile, producing an AAR +# that can be integrated into Android applications. +# +# Usage: +# ./build_gomobile_android.sh [command] [options] +# +# Commands: +# (no args) Build AAR (default) +# aar Build AAR (explicit) +# clean Clean build directory +# zip Build and create zip archive +# verify Verify existing AAR +# info Show build environment information +# help Show this help message +# +# Options: +# -v, --verbose Show verbose build output +# --no-optimize Skip optimization flags +# --backup Backup existing AAR before build +# --skip-install Skip auto-install of gomobile +# --min-sdk Override Android minSdkVersion (default 21) +# +# Output: +# build/Libwallet.aar +# +################################################################################ + +set -e +set -o pipefail + +################################################################################ +# CONSTANTS & CONFIGURATION +################################################################################ + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$SCRIPT_DIR" +BUILD_DIR="$PROJECT_DIR/build" +MOBILE_DIR="$PROJECT_DIR/mobile" + +AAR_NAME="Libwallet.aar" +MIN_GO_VERSION="1.21" +DEFAULT_MIN_SDK="21" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +# Flags +VERBOSE=false +OPTIMIZE=true +BACKUP=false +SKIP_INSTALL=false +MIN_SDK="$DEFAULT_MIN_SDK" + +BUILD_START_TIME=0 + +################################################################################ +# HELPER FUNCTIONS +################################################################################ + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_step() { echo -e "${CYAN}[STEP]${NC} $1"; } + +print_banner() { + echo -e "${BOLD}${BLUE}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " Gomobile Android Build Script - Libwallet" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo -e "${NC}" +} + +print_separator() { + echo -e "${BLUE}──────────────────────────────────────────────────────────────────────${NC}" +} + +start_timer() { BUILD_START_TIME=$(date +%s); } +get_elapsed_time() { echo $(( $(date +%s) - BUILD_START_TIME )); } + +format_duration() { + local s=$1 + if [ $s -lt 60 ]; then echo "${s}s"; else echo "$((s/60))m $((s%60))s"; fi +} + +format_size() { + local p=$1 + if [ -e "$p" ]; then du -sh "$p" 2>/dev/null | cut -f1 || echo "unknown"; else echo "unknown"; fi +} + +################################################################################ +# VERIFICATION +################################################################################ + +check_go_version() { + log_step "Checking Go installation..." + command -v go >/dev/null || { log_error "Go not installed"; exit 1; } + + local gv + gv=$(go version | awk '{print $3}' | sed 's/go//') + log_info "✓ Go $gv" +} + +check_gomobile() { + log_step "Checking gomobile..." + if command -v gomobile >/dev/null; then + log_info "✓ gomobile installed at $(which gomobile)" + return 0 + fi + log_warn "gomobile not found" + return 1 +} + +install_gomobile() { + if [ "$SKIP_INSTALL" = true ]; then + log_warn "Skipping gomobile installation (--skip-install)" + return 1 + fi + + log_step "Installing gomobile + gobind..." + go install golang.org/x/mobile/cmd/gomobile@latest + go install golang.org/x/mobile/cmd/gobind@latest + log_success "gomobile installed" + return 0 +} + +init_gomobile() { + log_step "Initializing gomobile..." + gomobile init + log_success "gomobile initialized" +} + +verify_mobile_package() { + log_step "Verifying mobile package..." + [ -d "$MOBILE_DIR" ] || { log_error "Missing: $MOBILE_DIR"; exit 1; } + [ -f "$MOBILE_DIR/mobile.go" ] || { log_error "Missing: $MOBILE_DIR/mobile.go"; exit 1; } + log_info "✓ Mobile package found at $MOBILE_DIR" +} + +check_android_env() { + log_step "Checking Android SDK/NDK environment..." + + # ANDROID_HOME / ANDROID_SDK_ROOT + if [ -z "${ANDROID_HOME:-}" ] && [ -z "${ANDROID_SDK_ROOT:-}" ]; then + log_warn "ANDROID_HOME/ANDROID_SDK_ROOT not set." + log_warn "Set one of them, e.g.: export ANDROID_SDK_ROOT=\$HOME/Android/Sdk" + else + log_info "✓ Android SDK: ${ANDROID_SDK_ROOT:-$ANDROID_HOME}" + fi + + # NDK + if [ -z "${ANDROID_NDK_HOME:-}" ]; then + log_warn "ANDROID_NDK_HOME not set." + log_warn "gomobile needs an NDK. Set it, e.g.: export ANDROID_NDK_HOME=\$HOME/Android/Sdk/ndk/" + else + log_info "✓ Android NDK: $ANDROID_NDK_HOME" + fi + + # We won't hard-fail here because some setups still work via local.properties, + # but if build fails, these are the first things to fix. +} + +################################################################################ +# BUILD +################################################################################ + +backup_existing() { + local aar_path="$BUILD_DIR/$AAR_NAME" + [ -f "$aar_path" ] || return 0 + local ts + ts=$(date +%Y%m%d_%H%M%S) + local backup="$BUILD_DIR/${AAR_NAME}.backup_${ts}" + log_step "Backing up existing AAR..." + cp "$aar_path" "$backup" + log_success "Backup created: $(basename "$backup")" +} + +build_aar() { + log_step "Building Android AAR (minSdk=$MIN_SDK)..." + mkdir -p "$BUILD_DIR" + + local verbose_flag="" + [ "$VERBOSE" = true ] && verbose_flag="-v" + + # Optimization flags + local ldflags=() + if [ "$OPTIMIZE" = true ]; then + ldflags=(-ldflags "-s -w") + fi + + # gomobile reads minSdk from env var + export ANDROID_API="$MIN_SDK" + + cd "$PROJECT_DIR" + + log_info "Running: gomobile bind -target=android -androidapi=$MIN_SDK ${ldflags[*]} $verbose_flag -o $BUILD_DIR/$AAR_NAME ./mobile" + + if [ "$VERBOSE" = true ]; then + gomobile bind -target=android -androidapi="$MIN_SDK" "${ldflags[@]}" -v -o "$BUILD_DIR/$AAR_NAME" ./mobile + else + gomobile bind -target=android -androidapi="$MIN_SDK" "${ldflags[@]}" -o "$BUILD_DIR/$AAR_NAME" ./mobile + fi + + log_success "AAR build completed" +} + +validate_aar() { + local aar_path="$BUILD_DIR/$AAR_NAME" + log_step "Validating AAR..." + + [ -f "$aar_path" ] || { log_error "AAR not found: $aar_path"; return 1; } + + # Quick sanity check: must contain classes.jar + AndroidManifest.xml + if unzip -l "$aar_path" | grep -q "classes.jar" && unzip -l "$aar_path" | grep -q "AndroidManifest.xml"; then + local size + size=$(format_size "$aar_path") + log_success "AAR validation passed ($size)" + return 0 + fi + + log_error "AAR structure doesn't look right (missing classes.jar or AndroidManifest.xml)" + return 1 +} + +create_zip() { + local aar_path="$BUILD_DIR/$AAR_NAME" + local zip_path="$BUILD_DIR/${AAR_NAME}.zip" + + log_step "Creating zip archive..." + [ -f "$aar_path" ] || { log_error "AAR not found. Build first."; return 1; } + + rm -f "$zip_path" + (cd "$BUILD_DIR" && zip -q -r "$(basename "$zip_path")" "$(basename "$aar_path")") + log_success "Zip created: $(basename "$zip_path") ($(format_size "$zip_path"))" +} + +clean_build() { + log_step "Cleaning build directory..." + rm -rf "$BUILD_DIR" + log_success "Build directory cleaned" +} + +show_info() { + print_banner + echo "Build Environment Information:" + print_separator + + if command -v go >/dev/null; then echo -e "${GREEN}Go:${NC} $(go version)"; else echo -e "${RED}Go:${NC} Not installed"; fi + if command -v gomobile >/dev/null; then echo -e "${GREEN}gomobile:${NC} $(which gomobile)"; else echo -e "${YELLOW}gomobile:${NC} Not installed"; fi + + echo -e "${GREEN}ANDROID_SDK_ROOT:${NC} ${ANDROID_SDK_ROOT:-"(not set)"}" + echo -e "${GREEN}ANDROID_HOME:${NC} ${ANDROID_HOME:-"(not set)"}" + echo -e "${GREEN}ANDROID_NDK_HOME:${NC} ${ANDROID_NDK_HOME:-"(not set)"}" + echo -e "${GREEN}ANDROID_API (minSdk):${NC} $MIN_SDK" + + print_separator + echo -e "${CYAN}Project Dir:${NC} $PROJECT_DIR" + echo -e "${CYAN}Build Dir:${NC} $BUILD_DIR" + echo -e "${CYAN}Mobile Dir:${NC} $MOBILE_DIR" + + if [ -f "$BUILD_DIR/$AAR_NAME" ]; then + print_separator + echo "Build Artifacts:" + echo -e "${CYAN}AAR:${NC} $BUILD_DIR/$AAR_NAME ($(format_size "$BUILD_DIR/$AAR_NAME"))" + fi + + print_separator +} + +show_help() { +cat << 'EOF' +Gomobile Android Build Script for libwallet + +Usage: ./build_gomobile_android.sh [command] [options] + +Commands: + (no args) Build AAR (default) + aar Build AAR + clean Clean build directory + zip Build and create zip archive + verify Verify existing build + info Show build environment information + help Show this help message + +Options: + -v, --verbose Show verbose build output + --no-optimize Skip optimization flags (-ldflags "-s -w") + --backup Backup existing AAR before build + --skip-install Skip auto-install of gomobile + --min-sdk Override minSdkVersion (default 21) + +Examples: + ./build_gomobile_android.sh + ./build_gomobile_android.sh aar --min-sdk 23 + ./build_gomobile_android.sh zip + ./build_gomobile_android.sh verify +EOF +} + +################################################################################ +# MAIN +################################################################################ + +COMMAND="${1:-aar}" +shift || true + +while [[ $# -gt 0 ]]; do + case $1 in + -v|--verbose) VERBOSE=true; shift ;; + --no-optimize) OPTIMIZE=false; shift ;; + --backup) BACKUP=true; shift ;; + --skip-install) SKIP_INSTALL=true; shift ;; + --min-sdk) MIN_SDK="${2:-$DEFAULT_MIN_SDK}"; shift 2 ;; + help|--help|-h) show_help; exit 0 ;; + *) log_error "Unknown option: $1"; echo "Run './build_gomobile_android.sh help'"; exit 1 ;; + esac +done + +main() { + if [ "$COMMAND" = "info" ]; then show_info; exit 0; fi + if [ "$COMMAND" = "clean" ]; then print_banner; clean_build; exit 0; fi + if [ "$COMMAND" = "verify" ]; then print_banner; validate_aar; exit $?; fi + + print_banner + start_timer + + echo "Build Configuration:" + echo -e "${CYAN}Command:${NC} $COMMAND" + echo -e "${CYAN}Min SDK:${NC} $MIN_SDK" + echo -e "${CYAN}Optimize:${NC} $OPTIMIZE" + echo -e "${CYAN}Verbose:${NC} $VERBOSE" + echo -e "${CYAN}Backup:${NC} $BACKUP" + print_separator + + check_go_version + verify_mobile_package + check_android_env + + if ! check_gomobile; then + install_gomobile + init_gomobile + fi + + print_separator + mkdir -p "$BUILD_DIR" + + [ "$BACKUP" = true ] && backup_existing + + case "$COMMAND" in + aar|"") build_aar ;; + zip) build_aar; create_zip ;; + *) log_error "Unknown command: $COMMAND"; echo "Run './build_gomobile_android.sh help'"; exit 1 ;; + esac + + print_separator + validate_aar + + local elapsed + elapsed=$(get_elapsed_time) + log_success "Build completed successfully in $(format_duration "$elapsed")" + echo -e "Output: ${CYAN}$BUILD_DIR/$AAR_NAME${NC} ($(format_size "$BUILD_DIR/$AAR_NAME"))" + print_separator +} + +main From 112568900249fab71e03630b9d39d8d660998ca5 Mon Sep 17 00:00:00 2001 From: dreacot Date: Tue, 30 Dec 2025 18:27:39 +0100 Subject: [PATCH 4/6] add libwllet files update build script --- build_gomobile_android.sh | 24 +- gomobile/addresses.go | 228 ++++++ gomobile/gomobile.go | 113 +++ gomobile/log.go | 37 + gomobile/sync.go | 268 +++++++ gomobile/transactions.go | 307 ++++++++ gomobile/types.go | 193 +++++ gomobile/utils.go | 20 + gomobile/walletloader.go | 287 ++++++++ mobile/mobile.go | 1391 ------------------------------------- 10 files changed, 1465 insertions(+), 1403 deletions(-) create mode 100644 gomobile/addresses.go create mode 100644 gomobile/gomobile.go create mode 100644 gomobile/log.go create mode 100644 gomobile/sync.go create mode 100644 gomobile/transactions.go create mode 100644 gomobile/types.go create mode 100644 gomobile/utils.go create mode 100644 gomobile/walletloader.go delete mode 100644 mobile/mobile.go diff --git a/build_gomobile_android.sh b/build_gomobile_android.sh index 03d5590..78dceb4 100755 --- a/build_gomobile_android.sh +++ b/build_gomobile_android.sh @@ -25,7 +25,7 @@ # --min-sdk Override Android minSdkVersion (default 21) # # Output: -# build/Libwallet.aar +# build/libwallet.aar # ################################################################################ @@ -39,9 +39,9 @@ set -o pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$SCRIPT_DIR" BUILD_DIR="$PROJECT_DIR/build" -MOBILE_DIR="$PROJECT_DIR/mobile" +GOMOBILE_DIR="$PROJECT_DIR/gomobile" -AAR_NAME="Libwallet.aar" +AAR_NAME="libwallet.aar" MIN_GO_VERSION="1.21" DEFAULT_MIN_SDK="21" @@ -76,7 +76,7 @@ log_step() { echo -e "${CYAN}[STEP]${NC} $1"; } print_banner() { echo -e "${BOLD}${BLUE}" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " Gomobile Android Build Script - Libwallet" + echo " Gomobile Android Build Script - libwallet" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo -e "${NC}" } @@ -141,10 +141,10 @@ init_gomobile() { } verify_mobile_package() { - log_step "Verifying mobile package..." - [ -d "$MOBILE_DIR" ] || { log_error "Missing: $MOBILE_DIR"; exit 1; } - [ -f "$MOBILE_DIR/mobile.go" ] || { log_error "Missing: $MOBILE_DIR/mobile.go"; exit 1; } - log_info "✓ Mobile package found at $MOBILE_DIR" + log_step "Verifying libwallet package..." + [ -d "$GOMOBILE_DIR" ] || { log_error "Missing: $GOMOBILE_DIR"; exit 1; } + [ -f "$GOMOBILE_DIR/gomobile.go" ] || { log_error "Missing: $GOMOBILE_DIR/gomobile.go"; exit 1; } + log_info "✓ Libwallet package found at $GOMOBILE_DIR" } check_android_env() { @@ -203,12 +203,12 @@ build_aar() { cd "$PROJECT_DIR" - log_info "Running: gomobile bind -target=android -androidapi=$MIN_SDK ${ldflags[*]} $verbose_flag -o $BUILD_DIR/$AAR_NAME ./mobile" + log_info "Running: gomobile bind -target=android -androidapi=$MIN_SDK ${ldflags[*]} $verbose_flag -o $BUILD_DIR/$AAR_NAME ./gomobile" if [ "$VERBOSE" = true ]; then - gomobile bind -target=android -androidapi="$MIN_SDK" "${ldflags[@]}" -v -o "$BUILD_DIR/$AAR_NAME" ./mobile + gomobile bind -target=android -androidapi="$MIN_SDK" "${ldflags[@]}" -v -o "$BUILD_DIR/$AAR_NAME" ./gomobile else - gomobile bind -target=android -androidapi="$MIN_SDK" "${ldflags[@]}" -o "$BUILD_DIR/$AAR_NAME" ./mobile + gomobile bind -target=android -androidapi="$MIN_SDK" "${ldflags[@]}" -o "$BUILD_DIR/$AAR_NAME" ./gomobile fi log_success "AAR build completed" @@ -266,7 +266,7 @@ show_info() { print_separator echo -e "${CYAN}Project Dir:${NC} $PROJECT_DIR" echo -e "${CYAN}Build Dir:${NC} $BUILD_DIR" - echo -e "${CYAN}Mobile Dir:${NC} $MOBILE_DIR" + echo -e "${CYAN}Gomobile Dir:${NC} $GOMOBILE_DIR" if [ -f "$BUILD_DIR/$AAR_NAME" ]; then print_separator diff --git a/gomobile/addresses.go b/gomobile/addresses.go new file mode 100644 index 0000000..5746d70 --- /dev/null +++ b/gomobile/addresses.go @@ -0,0 +1,228 @@ +package libwallet + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strconv" + + dcrwallet "decred.org/dcrwallet/v4/wallet" + "decred.org/dcrwallet/v4/wallet/udb" + "github.com/decred/dcrd/txscript/v4/stdaddr" + "github.com/decred/libwallet/dcr" +) + +// ----------------------------------------------------------------------------- +// Address Functions +// ----------------------------------------------------------------------------- + +func CurrentReceiveAddress(name string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q is not loaded", name) + } + + if !w.allowUnsyncedAddrs { + synced, _ := w.IsSynced(w.ctx) + if !synced { + return "", fmt.Errorf("currentReceiveAddress requested on an unsynced wallet (error code: %d)", ErrCodeNotSynced) + } + } + + addr, err := w.CurrentAddress(udb.DefaultAccountNum) + if err != nil { + return "", fmt.Errorf("w.CurrentAddress error: %v", err) + } + + return addr.String(), nil +} + +func NewExternalAddress(name string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q is not loaded", name) + } + + if !w.allowUnsyncedAddrs { + synced, _ := w.IsSynced(w.ctx) + if !synced { + return "", fmt.Errorf("newExternalAddress requested on an unsynced wallet (error code: %d)", ErrCodeNotSynced) + } + } + + _, err := w.NewExternalAddress(w.ctx, udb.DefaultAccountNum) + if err != nil { + return "", fmt.Errorf("w.NewExternalAddress error: %v", err) + } + + addr, err := w.CurrentAddress(udb.DefaultAccountNum) + if err != nil { + return "", fmt.Errorf("w.CurrentAddress error: %v", err) + } + + return addr.String(), nil +} + +func SignMessage(name, message, address, password string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q is not loaded", name) + } + + addr, err := stdaddr.DecodeAddress(address, w.MainWallet().ChainParams()) + if err != nil { + return "", fmt.Errorf("unable to decode address: %v", err) + } + + switch addr.(type) { + case *stdaddr.AddressPubKeyEcdsaSecp256k1V0: + case *stdaddr.AddressPubKeyHashEcdsaSecp256k1V0: + default: + return "", errors.New("invalid address type: must be P2PK or P2PKH") + } + + if err := w.MainWallet().Unlock(w.ctx, []byte(password), nil); err != nil { + return "", fmt.Errorf("cannot unlock wallet: %v", err) + } + defer w.MainWallet().Lock() + + sig, err := w.MainWallet().SignMessage(w.ctx, message, addr) + if err != nil { + return "", fmt.Errorf("unable to sign message: %v", err) + } + + return base64.StdEncoding.EncodeToString(sig), nil +} + +func VerifyMessage(name, message, address, sig string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q is not loaded", name) + } + + addr, err := stdaddr.DecodeAddress(address, w.MainWallet().ChainParams()) + if err != nil { + return "", fmt.Errorf("unable to decode address: %v", err) + } + + switch addr.(type) { + case *stdaddr.AddressPubKeyEcdsaSecp256k1V0: + case *stdaddr.AddressPubKeyHashEcdsaSecp256k1V0: + default: + return "", errors.New("invalid address type: must be P2PK or P2PKH") + } + + sigBytes, err := base64.StdEncoding.DecodeString(sig) + if err != nil { + return "", fmt.Errorf("unable to decode signature: %v", err) + } + + ok, err = dcrwallet.VerifyMessage(message, addr, sigBytes, w.MainWallet().ChainParams()) + if err != nil { + return "", fmt.Errorf("unable to verify message: %v", err) + } + + return fmt.Sprintf("%v", ok), nil +} + +func Addresses(name, nUsed, nUnused string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q is not loaded", name) + } + + nUsedVal, err := strconv.ParseUint(nUsed, 10, 32) + if err != nil { + return "", fmt.Errorf("number of used addresses is not a uint32: %v", err) + } + + nUnusedVal, err := strconv.ParseUint(nUnused, 10, 32) + if err != nil { + return "", fmt.Errorf("number of unused addresses is not a uint32: %v", err) + } + + used, unused, index, err := w.DefaultAccountAddresses(w.ctx, uint32(nUsedVal), uint32(nUnusedVal)) + if err != nil { + return "", fmt.Errorf("w.DefaultAccountAddresses error: %v", err) + } + + res := &AddressesRes{ + Used: used, + Unused: []string{}, + Index: index, + } + synced, _ := w.IsSynced(w.ctx) + if synced || w.allowUnsyncedAddrs { + res.Unused = unused + } + + b, err := json.Marshal(res) + if err != nil { + return "", fmt.Errorf("unable to marshal addresses: %v", err) + } + + return string(b), nil +} + +func DefaultPubkey(name string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q is not loaded", name) + } + + pubkey, err := w.AccountPubkey(w.ctx, defaultAccount) + if err != nil { + return "", fmt.Errorf("unable to get default pubkey: %v", err) + } + + return pubkey, nil +} + +func ValidateAddr(name, addr string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + validated, err := w.ValidateAddr(w.ctx, addr) + if err != nil { + return "", fmt.Errorf("unable to validate address: %v", err) + } + b, err := json.Marshal(validated) + if err != nil { + return "", fmt.Errorf("unable to marshal validate address: %v", err) + } + return string(b), nil +} + +func AddrFromExtendedKey(addrFromExtKeyJSON string) (string, error) { + var fromExt AddrFromExtKey + if err := json.Unmarshal([]byte(addrFromExtKeyJSON), &fromExt); err != nil { + return "", fmt.Errorf("malformed create addr json: %v", err) + } + addr, err := dcr.AddrFromExtendedKey(fromExt.Key, fromExt.Path, fromExt.AddrType, fromExt.UseChildBIP32Std) + if err != nil { + return "", fmt.Errorf("unable to create address: %v", err) + } + return addr, nil +} + +func CreateExtendedKey(createExtKeyJSON string) (string, error) { + var createExt CreateExtendedKeyReq + if err := json.Unmarshal([]byte(createExtKeyJSON), &createExt); err != nil { + return "", fmt.Errorf("malformed create extended key json: %v", err) + } + extKey, err := dcr.CreateExtendedKey( + createExt.Key, + createExt.ParentKey, + createExt.ChainCode, + createExt.Network, + uint8(createExt.Depth), + uint32(createExt.ChildN), + createExt.IsPrivate, + ) + if err != nil { + return "", fmt.Errorf("unable to create key: %v", err) + } + return extKey, nil +} diff --git a/gomobile/gomobile.go b/gomobile/gomobile.go new file mode 100644 index 0000000..07c98aa --- /dev/null +++ b/gomobile/gomobile.go @@ -0,0 +1,113 @@ +// Package libwallet exports Decred wallet functionalities for mobile platforms. +// This package is designed to be compiled with gomobile for iOS and Android. +// +// Build examples: +// gomobile bind -target=android -androidapi=23 -o ./build/libwallet.aar ./mobile +// gomobile bind -target=ios -o ./build/Libwallet.xcframework ./mobile +package libwallet + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/decred/libwallet/assetlog" + "github.com/decred/libwallet/dcr" + "github.com/decred/slog" +) + +// ----------------------------------------------------------------------------- +// Global variables (shared across files in this package) +// ----------------------------------------------------------------------------- + +var ( + mainCtx context.Context + cancelMainCtx context.CancelFunc + wg sync.WaitGroup + + logBackend *parentLogger + logMtx sync.RWMutex + log slog.Logger + + // walletsMtx protects wallets and initialized. + walletsMtx sync.RWMutex + wallets = make(map[string]*wallet) + initialized bool +) + +// ----------------------------------------------------------------------------- +// Core Functions +// ----------------------------------------------------------------------------- + +// Initialize initializes the libwallet mobile library. +func Initialize(logDir, logLvl string) (string, error) { + walletsMtx.Lock() + defer walletsMtx.Unlock() + if initialized { + return "", errors.New("duplicate initialization") + } + + lvl, ok := slog.LevelFromString(logLvl) + if !ok { + return "", fmt.Errorf("unknown log level %q", logLvl) + } + + if logDir != "" { + logSpinner, err := assetlog.NewRotator(logDir, "dcrwallet.log") + if err != nil { + return "", fmt.Errorf("error initializing log rotator: %v", err) + } + + logBackend = newParentLogger(logSpinner, lvl) + err = dcr.InitGlobalLogging(logDir, logBackend, lvl) + if err != nil { + return "", fmt.Errorf("error initializing logger for external pkgs: %v", err) + } + } else { + logBackend = newParentStdOutLogger(lvl) + } + + logMtx.Lock() + log = logBackend.SubLogger("APP") + logMtx.Unlock() + + mainCtx, cancelMainCtx = context.WithCancel(context.Background()) + + initialized = true + return "libwallet mobile initialized", nil +} + +// Shutdown shuts down the libwallet mobile library. +func Shutdown() (string, error) { + walletsMtx.Lock() + defer walletsMtx.Unlock() + if !initialized { + return "", errors.New("not initialized") + } + + logMtx.RLock() + log.Debug("libwallet mobile shutting down") + logMtx.RUnlock() + + for _, w := range wallets { + if err := w.CloseWallet(); err != nil { + w.log.Errorf("close wallet error: %v", err) + } + } + wallets = make(map[string]*wallet) + + // Stop all remaining background processes and wait for them to stop. + cancelMainCtx() + wg.Wait() + + // Close the logger backend as the last step. + logMtx.Lock() + log.Debug("libwallet mobile shutdown") + _ = logBackend.Close() + logBackend = nil + logMtx.Unlock() + + initialized = false + return "libwallet mobile shutdown", nil +} diff --git a/gomobile/log.go b/gomobile/log.go new file mode 100644 index 0000000..a1f2bf0 --- /dev/null +++ b/gomobile/log.go @@ -0,0 +1,37 @@ +package libwallet + +import ( + "os" + + "github.com/decred/slog" + "github.com/jrick/logrotate/rotator" +) + +func newParentLogger(r *rotator.Rotator, lvl slog.Level) *parentLogger { + return &parentLogger{ + Backend: slog.NewBackend(r), + rotator: r, + lvl: lvl, + } +} + +func newParentStdOutLogger(lvl slog.Level) *parentLogger { + backend := slog.NewBackend(os.Stdout) + return &parentLogger{ + Backend: backend, + lvl: lvl, + } +} + +func (pl *parentLogger) SubLogger(name string) slog.Logger { + logger := pl.Logger(name) + logger.SetLevel(pl.lvl) + return logger +} + +func (pl *parentLogger) Close() error { + if pl.rotator != nil { + return pl.rotator.Close() + } + return nil +} diff --git a/gomobile/sync.go b/gomobile/sync.go new file mode 100644 index 0000000..f723fbc --- /dev/null +++ b/gomobile/sync.go @@ -0,0 +1,268 @@ +package libwallet + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "decred.org/dcrwallet/v4/spv" + dcrwallet "decred.org/dcrwallet/v4/wallet" +) + +// ----------------------------------------------------------------------------- +// Sync Functions +// ----------------------------------------------------------------------------- + +func SyncWallet(name, peers string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + + var peerList []string + for _, p := range strings.Split(peers, ",") { + if p = strings.TrimSpace(p); p != "" { + peerList = append(peerList, p) + } + } + + ntfns := &spv.Notifications{ + Synced: func(sync bool) { + w.syncStatusMtx.Lock() + w.syncStatusCode = SSCComplete + w.syncStatusMtx.Unlock() + w.log.Debug("Sync completed.") + }, + PeerConnected: func(peerCount int32, addr string) { + w.syncStatusMtx.Lock() + w.numPeers = int(peerCount) + w.syncStatusMtx.Unlock() + w.log.Debugf("Connected to peer at %s. %d total peers.", addr, peerCount) + }, + PeerDisconnected: func(peerCount int32, addr string) { + w.syncStatusMtx.Lock() + w.numPeers = int(peerCount) + w.syncStatusMtx.Unlock() + w.log.Debugf("Disconnected from peer at %s. %d total peers.", addr, peerCount) + }, + FetchMissingCFiltersStarted: func() { + w.syncStatusMtx.Lock() + if w.rescanning { + w.syncStatusMtx.Unlock() + return + } + w.syncStatusCode = SSCFetchingCFilters + w.syncStatusMtx.Unlock() + w.log.Debug("Fetching missing cfilters started.") + }, + FetchMissingCFiltersProgress: func(startCFiltersHeight, endCFiltersHeight int32) { + w.syncStatusMtx.Lock() + w.cfiltersHeight = int(endCFiltersHeight) + w.syncStatusMtx.Unlock() + w.log.Debugf("Fetching cfilters from %d to %d.", startCFiltersHeight, endCFiltersHeight) + }, + FetchMissingCFiltersFinished: func() { + w.syncStatusMtx.Lock() + w.cfiltersHeight = w.targetHeight + w.syncStatusMtx.Unlock() + w.log.Debug("Finished fetching missing cfilters.") + }, + FetchHeadersStarted: func() { + w.syncStatusMtx.Lock() + if w.rescanning { + w.syncStatusMtx.Unlock() + return + } + w.syncStatusCode = SSCFetchingHeaders + w.syncStatusMtx.Unlock() + w.log.Debug("Fetching headers started.") + }, + FetchHeadersProgress: func(lastHeaderHeight int32, lastHeaderTime int64) { + w.syncStatusMtx.Lock() + w.headersHeight = int(lastHeaderHeight) + w.syncStatusMtx.Unlock() + w.log.Debugf("Fetching headers to %d.", lastHeaderHeight) + }, + FetchHeadersFinished: func() { + w.syncStatusMtx.Lock() + w.headersHeight = w.targetHeight + w.syncStatusMtx.Unlock() + w.log.Debug("Fetching headers finished.") + }, + DiscoverAddressesStarted: func() { + w.syncStatusMtx.Lock() + if w.rescanning { + w.syncStatusMtx.Unlock() + return + } + w.syncStatusCode = SSCDiscoveringAddrs + w.syncStatusMtx.Unlock() + w.log.Debug("Discover addresses started.") + }, + DiscoverAddressesFinished: func() { + w.log.Debug("Discover addresses finished.") + }, + RescanStarted: func() { + w.syncStatusMtx.Lock() + if w.rescanning { + w.syncStatusMtx.Unlock() + return + } + w.syncStatusCode = SSCRescanning + w.syncStatusMtx.Unlock() + w.log.Debug("Rescan started.") + }, + RescanProgress: func(rescannedThrough int32) { + w.syncStatusMtx.Lock() + w.rescanHeight = int(rescannedThrough) + w.syncStatusMtx.Unlock() + w.log.Debugf("Rescanned through block %d.", rescannedThrough) + }, + RescanFinished: func() { + w.syncStatusMtx.Lock() + w.rescanHeight = w.targetHeight + w.syncStatusMtx.Unlock() + w.log.Debug("Rescan finished.") + }, + } + + if err := w.StartSync(w.ctx, ntfns, peerList...); err != nil { + return "", err + } + return "sync started", nil +} + +func SyncWalletStatus(name string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + + w.syncStatusMtx.RLock() + ssc, cfh, hh, rh, np := w.syncStatusCode, w.cfiltersHeight, w.headersHeight, w.rescanHeight, w.numPeers + w.syncStatusMtx.RUnlock() + + synced, targetHeight := w.IsSynced(w.ctx) + w.syncStatusMtx.Lock() + if ssc != SSCComplete && synced && !w.rescanning { + ssc = SSCComplete + w.syncStatusCode = ssc + } + w.syncStatusMtx.Unlock() + + ss := &SyncStatusRes{ + SyncStatusCode: int(ssc), + SyncStatus: ssc.String(), + TargetHeight: int(targetHeight), + NumPeers: np, + } + switch ssc { + case SSCFetchingCFilters: + ss.CFiltersHeight = cfh + case SSCFetchingHeaders: + ss.HeadersHeight = hh + case SSCRescanning: + ss.RescanHeight = rh + } + + b, err := json.Marshal(ss) + if err != nil { + return "", fmt.Errorf("unable to marshal sync status result: %v", err) + } + return string(b), nil +} + +func RescanFromHeight(name, height string) (string, error) { + heightVal, err := strconv.ParseUint(height, 10, 32) + if err != nil { + return "", fmt.Errorf("height is not an uint32: %v", err) + } + + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + + synced, _ := w.IsSynced(w.ctx) + if !synced { + return "", fmt.Errorf("rescanFromHeight requested on an unsynced wallet (error code: %d)", ErrCodeNotSynced) + } + + w.syncStatusMtx.Lock() + if w.rescanning { + w.syncStatusMtx.Unlock() + return "", fmt.Errorf("wallet %q already rescanning", name) + } + w.syncStatusCode = SSCRescanning + w.rescanning = true + w.rescanHeight = int(heightVal) + w.syncStatusMtx.Unlock() + + w.Add(1) + go func() { + defer func() { + w.syncStatusMtx.Lock() + w.syncStatusCode = SSCComplete + w.rescanning = false + w.syncStatusMtx.Unlock() + w.Done() + }() + + prog := make(chan dcrwallet.RescanProgress) + go func() { + w.RescanProgressFromHeight(w.ctx, int32(heightVal), prog) + }() + + for { + select { + case p, open := <-prog: + if !open { + return + } + if p.Err != nil { + logMtx.RLock() + log.Errorf("rescan wallet %q error: %v", name, p.Err) + logMtx.RUnlock() + return + } + w.syncStatusMtx.Lock() + w.rescanHeight = int(p.ScannedThrough) + w.syncStatusMtx.Unlock() + case <-w.ctx.Done(): + return + } + } + }() + + return fmt.Sprintf("rescan from height %d for wallet %q started", heightVal, name), nil +} + +func BirthState(name string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q is not loaded", name) + } + + bs, err := w.MainWallet().BirthState(w.ctx) + if err != nil { + return "", fmt.Errorf("wallet.BirthState error: %v", err) + } + if bs == nil { + return "", fmt.Errorf("birth state is nil for wallet %q", name) + } + + bsRes := &BirthdayStateRes{ + Hash: bs.Hash.String(), + Height: bs.Height, + Time: bs.Time.Unix(), + SetFromHeight: bs.SetFromHeight, + SetFromTime: bs.SetFromTime, + } + + b, err := json.Marshal(bsRes) + if err != nil { + return "", fmt.Errorf("unable to marshal birth state result: %v", err) + } + return string(b), nil +} diff --git a/gomobile/transactions.go b/gomobile/transactions.go new file mode 100644 index 0000000..d4be76f --- /dev/null +++ b/gomobile/transactions.go @@ -0,0 +1,307 @@ +package libwallet + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "math" + "strconv" + + dcrwallet "decred.org/dcrwallet/v4/wallet" + "github.com/decred/dcrd/chaincfg/chainhash" + "github.com/decred/dcrd/txscript/v4/stdaddr" + "github.com/decred/libwallet/dcr" +) + +// ----------------------------------------------------------------------------- +// Transaction Functions +// ----------------------------------------------------------------------------- + +func CreateTransaction(name, createTxReqJSON string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + + var req CreateTxReq + if err := json.Unmarshal([]byte(createTxReqJSON), &req); err != nil { + return "", fmt.Errorf("malformed sign send request: %v", err) + } + + outputs := make([]*dcr.Output, len(req.Outputs)) + for i, out := range req.Outputs { + outputs[i] = &dcr.Output{Address: out.Address, Amount: uint64(out.Amount)} + } + + inputs := make([]*dcr.Input, len(req.Inputs)) + for i, in := range req.Inputs { + inputs[i] = &dcr.Input{TxID: in.TxID, Vout: uint32(in.Vout)} + } + + ignoreInputs := make([]*dcr.Input, len(req.IgnoreInputs)) + for i, in := range req.IgnoreInputs { + ignoreInputs[i] = &dcr.Input{TxID: in.TxID, Vout: uint32(in.Vout)} + } + + if req.Sign { + if err := w.MainWallet().Unlock(w.ctx, []byte(req.Password), nil); err != nil { + return "", fmt.Errorf("cannot unlock wallet: %v", err) + } + defer w.MainWallet().Lock() + } + + txBytes, txhash, fee, err := w.CreateTransaction(w.ctx, outputs, inputs, ignoreInputs, uint64(req.FeeRate), req.SendAll, req.Sign) + if err != nil { + return "", fmt.Errorf("unable to sign send transaction: %v", err) + } + + res := &CreateTxRes{ + Hex: hex.EncodeToString(txBytes), + Txid: txhash.String(), + Fee: int(fee), + } + + b, err := json.Marshal(res) + if err != nil { + return "", fmt.Errorf("unable to marshal sign send transaction result: %v", err) + } + return string(b), nil +} + +func SendRawTransaction(name, txHex string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + + txHash, err := w.SendRawTransaction(w.ctx, txHex) + if err != nil { + return "", fmt.Errorf("unable to send raw transaction: %v", err) + } + return txHash.String(), nil +} + +func ListUnspents(name string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + + res, err := w.MainWallet().ListUnspent(w.ctx, 1, math.MaxInt32, nil, defaultAccount) + if err != nil { + return "", fmt.Errorf("unable to get unspents: %v", err) + } + + type ListUnspentRes struct { + TxID string `json:"txid"` + Vout uint32 `json:"vout"` + Tree int8 `json:"tree"` + TxType int `json:"txtype"` + Address string `json:"address"` + Account string `json:"account"` + ScriptPubKey string `json:"scriptPubKey"` + RedeemScript string `json:"redeemScript,omitempty"` + Amount float64 `json:"amount"` + Confirmations int64 `json:"confirmations"` + Spendable bool `json:"spendable"` + IsChange bool `json:"ischange"` + } + + unspentRes := make([]ListUnspentRes, len(res)) + for i, unspent := range res { + addr, err := stdaddr.DecodeAddress(unspent.Address, w.MainWallet().ChainParams()) + if err != nil { + return "", fmt.Errorf("unable to decode address: %v", err) + } + + ka, err := w.MainWallet().KnownAddress(w.ctx, addr) + if err != nil { + return "", fmt.Errorf("unspent address is not known: %v", err) + } + + isChange := false + if ka, ok := ka.(dcrwallet.BIP0044Address); ok { + _, branch, _ := ka.Path() + isChange = branch == 1 + } + + unspentRes[i] = ListUnspentRes{ + TxID: unspent.TxID, + Vout: unspent.Vout, + Tree: unspent.Tree, + TxType: unspent.TxType, + Address: unspent.Address, + Account: unspent.Account, + ScriptPubKey: unspent.ScriptPubKey, + RedeemScript: unspent.RedeemScript, + Amount: unspent.Amount, + Confirmations: unspent.Confirmations, + Spendable: unspent.Spendable, + IsChange: isChange, + } + } + + b, err := json.Marshal(unspentRes) + if err != nil { + return "", fmt.Errorf("unable to marshal list unspents result: %v", err) + } + return string(b), nil +} + +func EstimateFee(name, nBlocks string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + + nBlocksVal, err := strconv.ParseUint(nBlocks, 10, 64) + if err != nil { + return "", fmt.Errorf("number of blocks is not a uint64: %v", err) + } + + txFee, err := w.FetchFeeFromOracle(w.ctx, nBlocksVal) + if err != nil { + return "", fmt.Errorf("unable to get fee from oracle: %v", err) + } + + return fmt.Sprintf("%d", uint64(txFee*1e8)), nil +} + +func ListTransactions(name, from, count string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + + fromVal, err := strconv.ParseInt(from, 10, 32) + if err != nil { + return "", fmt.Errorf("from is not an int: %v", err) + } + + countVal, err := strconv.ParseInt(count, 10, 32) + if err != nil { + return "", fmt.Errorf("count is not an int: %v", err) + } + + res, err := w.MainWallet().ListTransactions(w.ctx, int(fromVal), int(countVal)) + if err != nil { + return "", fmt.Errorf("unable to get transactions: %v", err) + } + + _, blockHeight := w.MainWallet().MainChainTip(w.ctx) + + ltRes := make([]*ListTransactionRes, len(res)) + for i, ltw := range res { + receiveTime := ltw.TimeReceived + if ltw.BlockTime != 0 && ltw.BlockTime < ltw.TimeReceived { + receiveTime = ltw.BlockTime + } + + var height int64 + if ltw.Confirmations > 0 { + height = int64(blockHeight) - ltw.Confirmations + 1 + } + + ltRes[i] = &ListTransactionRes{ + Address: ltw.Address, + Amount: ltw.Amount, + Category: ltw.Category, + Confirmations: ltw.Confirmations, + Height: height, + Fee: ltw.Fee, + Time: receiveTime, + TxID: ltw.TxID, + Vout: ltw.Vout, + } + } + + b, err := json.Marshal(ltRes) + if err != nil { + return "", fmt.Errorf("unable to marshal list transactions result: %v", err) + } + return string(b), nil +} + +func BestBlock(name string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + + blockHash, blockHeight := w.MainWallet().MainChainTip(w.ctx) + res := &BestBlockRes{Hash: blockHash.String(), Height: int(blockHeight)} + + b, err := json.Marshal(res) + if err != nil { + return "", fmt.Errorf("unable to marshal best block result: %v", err) + } + return string(b), nil +} + +func DecodeTx(name, txHex string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + + decoded, err := w.DecodeTx(txHex) + if err != nil { + return "", fmt.Errorf("unable to decode tx: %v", err) + } + + b, err := json.Marshal(decoded) + if err != nil { + return "", fmt.Errorf("unable to marshal decoded tx: %v", err) + } + return string(b), nil +} + +func GetTxn(name, hashesJSON string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + + var txIDs []string + if err := json.Unmarshal([]byte(hashesJSON), &txIDs); err != nil { + return "", fmt.Errorf("unable to unmarshal hashes: %v", err) + } + + txHashes := make([]*chainhash.Hash, len(txIDs)) + for i, txID := range txIDs { + txHash, err := chainhash.NewHashFromStr(txID) + if err != nil { + return "", fmt.Errorf("unable to create tx hash: %v", err) + } + txHashes[i] = txHash + } + + hexes, err := w.GetTxn(w.ctx, txHashes) + if err != nil { + return "", fmt.Errorf("unable to get txn: %v", err) + } + + b, err := json.Marshal(hexes) + if err != nil { + return "", fmt.Errorf("unable to marshal txn: %v", err) + } + return string(b), nil +} + +func AddSigs(name, txHex, sigScriptsJSON string) (string, error) { + w, exists := loadedWallet(name) + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + + var sigScripts []string + if err := json.Unmarshal([]byte(sigScriptsJSON), &sigScripts); err != nil { + return "", fmt.Errorf("unable to unmarshal sig scripts: %v", err) + } + + signedHex, err := w.AddSigs(txHex, sigScripts) + if err != nil { + return "", fmt.Errorf("unable sign tx: %v", err) + } + return signedHex, nil +} diff --git a/gomobile/types.go b/gomobile/types.go new file mode 100644 index 0000000..86dd92a --- /dev/null +++ b/gomobile/types.go @@ -0,0 +1,193 @@ +package libwallet + +import ( + "context" + "sync" + + "decred.org/dcrwallet/v4/spv" + "github.com/decred/libwallet/dcr" + "github.com/decred/slog" + "github.com/jrick/logrotate/rotator" +) + +// ----------------------------------------------------------------------------- +// Public types/constants (used by callers via JSON payloads) +// ----------------------------------------------------------------------------- + +const ( + // ErrCodeNotSynced is returned when the wallet must be synced to perform an + // action but is not. + ErrCodeNotSynced = 1 + + defaultAccount = "default" +) + +// SyncStatusCode represents the sync status of a wallet. +type SyncStatusCode int + +const ( + SSCNotStarted SyncStatusCode = iota + SSCFetchingCFilters + SSCFetchingHeaders + SSCDiscoveringAddrs + SSCRescanning + SSCComplete +) + +func (ssc SyncStatusCode) String() string { + return [...]string{ + "not started", + "fetching cfilters", + "fetching headers", + "discovering addresses", + "rescanning", + "sync complete", + }[ssc] +} + +// SyncStatusRes represents the sync status response. +type SyncStatusRes struct { + SyncStatusCode int `json:"syncstatuscode"` + SyncStatus string `json:"syncstatus"` + TargetHeight int `json:"targetheight"` + NumPeers int `json:"numpeers"` + CFiltersHeight int `json:"cfiltersheight,omitempty"` + HeadersHeight int `json:"headersheight,omitempty"` + RescanHeight int `json:"rescanheight,omitempty"` +} + +// Input represents a transaction input. +type Input struct { + TxID string `json:"txid"` + Vout int `json:"vout"` +} + +// Output represents a transaction output. +type Output struct { + Address string `json:"address"` + Amount int `json:"amount"` +} + +// CreateTxReq represents a create transaction request. +type CreateTxReq struct { + Outputs []Output `json:"outputs"` + Inputs []Input `json:"inputs"` + IgnoreInputs []Input `json:"ignoreinputs"` + FeeRate int `json:"feerate"` + SendAll bool `json:"sendall"` + Password string `json:"password"` + Sign bool `json:"sign"` +} + +// CreateTxRes represents a create transaction response. +type CreateTxRes struct { + Hex string `json:"hex"` + Txid string `json:"txid"` + Fee int `json:"fee"` +} + +// BestBlockRes represents the best block response. +type BestBlockRes struct { + Hash string `json:"hash"` + Height int `json:"height"` +} + +// ListTransactionRes represents a list transaction response. +type ListTransactionRes struct { + Address string `json:"address,omitempty"` + Amount float64 `json:"amount"` + Category string `json:"category"` + Confirmations int64 `json:"confirmations"` + Height int64 `json:"height"` + Fee *float64 `json:"fee,omitempty"` + Time int64 `json:"time"` + TxID string `json:"txid"` + Vout uint32 `json:"vout"` +} + +// BirthdayStateRes represents the birthday state response. +type BirthdayStateRes struct { + Hash string `json:"hash"` + Height uint32 `json:"height"` + Time int64 `json:"time"` + SetFromHeight bool `json:"setfromheight"` + SetFromTime bool `json:"setfromtime"` +} + +// AddressesRes represents the addresses response. +type AddressesRes struct { + Used []string `json:"used"` + Unused []string `json:"unused"` + Index uint32 `json:"index"` +} + +// Config represents the wallet configuration. +type Config struct { + Name string `json:"name"` + // Allow getting unused addresses when not synced. + AllowUnsyncedAddrs bool `json:"unsyncedaddrs"` + Net string `json:"net"` + DataDir string `json:"datadir"` + // Only needed during creation. + Birthday int64 `json:"birthday"` + Pass string `json:"pass"` + Mnemonic string `json:"mnemonic"` + SeedPass string `json:"seedpass"` + // If the wallet existed before but the db was deleted to reduce + // storage, restore from the local encrypted seed using the provided + // password. Also works for watching only wallets with no password. + UseLocalSeed bool `json:"uselocalseed"` + // Only needed during watching only creation. + PubKey string `json:"pubkey"` +} + +// AddrFromExtKey represents the address from extended key request. +type AddrFromExtKey struct { + Key string `json:"key"` + Path string `json:"path"` + // Currently support types: P2PKH + AddrType string `json:"addrtype"` + UseChildBIP32Std bool `json:"usechildbip32std"` +} + +// CreateExtendedKeyReq represents the create extended key request. +// Note: Depth uses int instead of uint8 because gomobile doesn't handle uint8 well +// when generating Objective-C bindings (uint8 maps to 'byte' which conflicts with macOS types). +type CreateExtendedKeyReq struct { + Key string `json:"key"` + ParentKey string `json:"parentkey"` + ChainCode string `json:"chaincode"` + Network string `json:"network"` + Depth int `json:"depth"` + ChildN int `json:"childn"` + IsPrivate bool `json:"isprivate"` +} + +// ----------------------------------------------------------------------------- +// Internal types +// ----------------------------------------------------------------------------- + +type wallet struct { + *dcr.Wallet + log slog.Logger + + sync.WaitGroup + ctx context.Context + cancelCtx context.CancelFunc + + syncStatusMtx sync.RWMutex + syncStatusCode SyncStatusCode + targetHeight, cfiltersHeight, headersHeight, rescanHeight, numPeers int + rescanning, allowUnsyncedAddrs bool +} + +// spv.Notifications is referenced in sync.go; keep import here to ensure +// package compilation includes it when only types are used in a build. +var _ = spv.Notifications{} + +// parentLogger wraps slog backend with optional rotator. +type parentLogger struct { + *slog.Backend + rotator *rotator.Rotator + lvl slog.Level +} diff --git a/gomobile/utils.go b/gomobile/utils.go new file mode 100644 index 0000000..bd5fe18 --- /dev/null +++ b/gomobile/utils.go @@ -0,0 +1,20 @@ +package libwallet + +// ----------------------------------------------------------------------------- +// Helper functions +// ----------------------------------------------------------------------------- + +func loadedWallet(name string) (*wallet, bool) { + walletsMtx.RLock() + defer walletsMtx.RUnlock() + + w, ok := wallets[name] + if !ok { + logMtx.RLock() + if log != nil { + log.Debugf("attempted to use an unloaded wallet %q", name) + } + logMtx.RUnlock() + } + return w, ok +} diff --git a/gomobile/walletloader.go b/gomobile/walletloader.go new file mode 100644 index 0000000..4330ff2 --- /dev/null +++ b/gomobile/walletloader.go @@ -0,0 +1,287 @@ +package libwallet + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + dexmnemonic "decred.org/dcrdex/client/mnemonic" + "github.com/decred/libwallet/dcr" + "github.com/decred/libwallet/mnemonic" +) + +// ----------------------------------------------------------------------------- +// Wallet Management +// ----------------------------------------------------------------------------- + +// CreateWallet creates a new wallet with the given config JSON. +func CreateWallet(configJSON string) (string, error) { + walletsMtx.Lock() + defer walletsMtx.Unlock() + if !initialized { + return "", errors.New("libwallet is not initialized") + } + + var cfg Config + if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { + return "", fmt.Errorf("malformed config: %v", err) + } + + name := cfg.Name + if _, exists := wallets[name]; exists { + return "", fmt.Errorf("wallet already exists with name: %q", name) + } + + logger := logBackend.SubLogger(name) + params := dcr.CreateWalletParams{ + OpenWalletParams: dcr.OpenWalletParams{ + Net: cfg.Net, + DataDir: cfg.DataDir, + DbDriver: "bdb", // keep consistent with current mobile setup + Logger: logger, + }, + Pass: []byte(cfg.Pass), + } + + var recoveryConfig *dcr.RecoveryCfg + if cfg.Mnemonic != "" { + var ( + seed []byte + birthday time.Time + seedType dcr.SeedType + err error + ) + nWords := len(strings.Fields(cfg.Mnemonic)) + switch nWords { + case 15: + seed, birthday, err = dexmnemonic.DecodeMnemonic(cfg.Mnemonic) + seedType = dcr.STFifteenWords + case 12: + seed, err = mnemonic.DecodeMnemonic(cfg.Mnemonic) + birthday = time.Unix(cfg.Birthday, 0) + seedType = dcr.STTwelveWords + case 24: + seed, err = mnemonic.DecodeMnemonic(cfg.Mnemonic) + birthday = time.Unix(cfg.Birthday, 0) + seedType = dcr.STTwentyFourWords + default: + return "", fmt.Errorf("unknown mnemonic format. expected 12, 15, or 24 words, got %d", nWords) + } + if err != nil { + return "", fmt.Errorf("unable to decode wallet mnemonic: %v", err) + } + recoveryConfig = &dcr.RecoveryCfg{ + Seed: seed, + SeedPass: []byte(cfg.SeedPass), + SeedType: seedType, + Birthday: birthday, + } + } + if cfg.UseLocalSeed { + recoveryConfig = &dcr.RecoveryCfg{UseLocalSeed: true} + } + + walletCtx, cancel := context.WithCancel(mainCtx) + + w, err := dcr.CreateWallet(walletCtx, params, recoveryConfig) + if err != nil { + cancel() + return "", err + } + + wallets[name] = &wallet{ + Wallet: w, + log: logger, + ctx: walletCtx, + cancelCtx: cancel, + allowUnsyncedAddrs: cfg.AllowUnsyncedAddrs, + } + return "wallet created", nil +} + +// CreateWatchOnlyWallet creates a watch-only wallet with the given config JSON. +func CreateWatchOnlyWallet(configJSON string) (string, error) { + walletsMtx.Lock() + defer walletsMtx.Unlock() + if !initialized { + return "", errors.New("libwallet is not initialized") + } + + var cfg Config + if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { + return "", fmt.Errorf("malformed config: %v", err) + } + + name := cfg.Name + if _, exists := wallets[name]; exists { + return "", fmt.Errorf("wallet already exists with name: %q", name) + } + + logger := logBackend.SubLogger(name) + params := dcr.CreateWalletParams{ + OpenWalletParams: dcr.OpenWalletParams{ + Net: cfg.Net, + DataDir: cfg.DataDir, + DbDriver: "bdb", + Logger: logger, + }, + } + + walletCtx, cancel := context.WithCancel(mainCtx) + + w, err := dcr.CreateWatchOnlyWallet(walletCtx, cfg.PubKey, params, cfg.UseLocalSeed) + if err != nil { + cancel() + return "", err + } + + wallets[name] = &wallet{ + Wallet: w, + log: logger, + ctx: walletCtx, + cancelCtx: cancel, + allowUnsyncedAddrs: cfg.AllowUnsyncedAddrs, + } + return "wallet created", nil +} + +// LoadWallet loads an existing wallet. +func LoadWallet(configJSON string) (string, error) { + walletsMtx.Lock() + defer walletsMtx.Unlock() + if !initialized { + return "", errors.New("libwallet is not initialized") + } + + var cfg Config + if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { + return "", fmt.Errorf("malformed config: %v", err) + } + + name := cfg.Name + if _, exists := wallets[name]; exists { + return "wallet already loaded", nil // not an error, already loaded + } + + logger := logBackend.SubLogger(name) + params := dcr.OpenWalletParams{ + Net: cfg.Net, + DataDir: cfg.DataDir, + DbDriver: "bdb", + Logger: logger, + } + + walletCtx, cancel := context.WithCancel(mainCtx) + + w, err := dcr.LoadWallet(walletCtx, params) + if err != nil { + cancel() + return "", err + } + + if err = w.OpenWallet(walletCtx); err != nil { + cancel() + return "", err + } + + wallets[name] = &wallet{ + Wallet: w, + log: logger, + ctx: walletCtx, + cancelCtx: cancel, + allowUnsyncedAddrs: cfg.AllowUnsyncedAddrs, + } + return fmt.Sprintf("wallet %q loaded", name), nil +} + +// CloseWallet closes a wallet. +func CloseWallet(name string) (string, error) { + walletsMtx.Lock() + defer walletsMtx.Unlock() + + w, exists := wallets[name] + if !exists { + return "", fmt.Errorf("wallet with name %q does not exist", name) + } + w.cancelCtx() + w.Wait() + if err := w.CloseWallet(); err != nil { + return "", fmt.Errorf("close wallet %q error: %v", name, err) + } + delete(wallets, name) + return fmt.Sprintf("wallet %q shutdown", name), nil +} + +// WalletSeed returns the wallet seed. +func WalletSeed(name, pass string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q not loaded", name) + } + + seed, err := w.DecryptSeed([]byte(pass)) + if err != nil { + return "", fmt.Errorf("w.DecryptSeed error: %v", err) + } + + return seed, nil +} + +// WalletBalance returns the wallet balance as JSON. +func WalletBalance(name string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q not loaded", name) + } + + const confs = 1 + bals, err := w.AccountBalances(w.ctx, confs) + if err != nil { + return "", fmt.Errorf("w.AccountBalances error: %v", err) + } + + balMap := map[string]int64{ + "confirmed": 0, + "unconfirmed": 0, + } + + for _, bal := range bals { + balMap["confirmed"] += int64(bal.Spendable) + balMap["unconfirmed"] += int64(bal.Total) - int64(bal.Spendable) + } + + balJSON, err := json.Marshal(balMap) + if err != nil { + return "", fmt.Errorf("marshal balMap error: %v", err) + } + + return string(balJSON), nil +} + +// ChangePassphrase changes the wallet passphrase. +func ChangePassphrase(name, oldPass, newPass string) (string, error) { + w, ok := loadedWallet(name) + if !ok { + return "", fmt.Errorf("wallet with name %q not loaded", name) + } + + oldPassBytes, newPassBytes := []byte(oldPass), []byte(newPass) + if err := w.MainWallet().ChangePrivatePassphrase(w.ctx, oldPassBytes, newPassBytes); err != nil { + return "", fmt.Errorf("w.ChangePrivatePassphrase error: %v", err) + } + + if err := w.ReEncryptSeed(oldPassBytes, newPassBytes); err != nil { + // Undo the passphrase change, since re-encrypting the seed failed. + if undoErr := w.MainWallet().ChangePrivatePassphrase(w.ctx, newPassBytes, oldPassBytes); undoErr != nil { + logMtx.RLock() + log.Errorf("error undoing passphrase change: %v", undoErr) + logMtx.RUnlock() + } + return "", fmt.Errorf("w.ReEncryptSeed error: %v", err) + } + + return "passphrase changed", nil +} diff --git a/mobile/mobile.go b/mobile/mobile.go deleted file mode 100644 index f3ac678..0000000 --- a/mobile/mobile.go +++ /dev/null @@ -1,1391 +0,0 @@ -// Package mobile exports Decred wallet functionalities for mobile platforms. -// This package is designed to be compiled with gomobile for iOS and Android. -// -// Build cmd: gomobile bind -target=ios -o ./build/Libwallet.xcframework ./mobile -package mobile - -import ( - "context" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "math" - "os" - "strconv" - "strings" - "sync" - "time" - - dexmnemonic "decred.org/dcrdex/client/mnemonic" - "decred.org/dcrwallet/v4/spv" - dcrwallet "decred.org/dcrwallet/v4/wallet" - "decred.org/dcrwallet/v4/wallet/udb" - "github.com/decred/dcrd/chaincfg/chainhash" - "github.com/decred/dcrd/txscript/v4/stdaddr" - "github.com/decred/libwallet/assetlog" - "github.com/decred/libwallet/dcr" - "github.com/decred/libwallet/mnemonic" - "github.com/decred/slog" - "github.com/jrick/logrotate/rotator" -) - -// ----------------------------------------------------------------------------- -// Global variables -// ----------------------------------------------------------------------------- - -var ( - mainCtx context.Context - cancelMainCtx context.CancelFunc - wg sync.WaitGroup - - logBackend *parentLogger - logMtx sync.RWMutex - log slog.Logger - - // walletsMtx protects wallets and initialized. - walletsMtx sync.RWMutex - wallets = make(map[string]*wallet) - initialized bool -) - -// ----------------------------------------------------------------------------- -// Types -// ----------------------------------------------------------------------------- - -const ( - // ErrCodeNotSynced is returned when the wallet must be synced to perform an - // action but is not. - ErrCodeNotSynced = 1 - - defaultAccount = "default" -) - -// SyncStatusCode represents the sync status of a wallet. -type SyncStatusCode int - -const ( - SSCNotStarted SyncStatusCode = iota - SSCFetchingCFilters - SSCFetchingHeaders - SSCDiscoveringAddrs - SSCRescanning - SSCComplete -) - -func (ssc SyncStatusCode) String() string { - return [...]string{"not started", "fetching cfilters", "fetching headers", - "discovering addresses", "rescanning", "sync complete"}[ssc] -} - -// SyncStatusRes represents the sync status response. -type SyncStatusRes struct { - SyncStatusCode int `json:"syncstatuscode"` - SyncStatus string `json:"syncstatus"` - TargetHeight int `json:"targetheight"` - NumPeers int `json:"numpeers"` - CFiltersHeight int `json:"cfiltersheight,omitempty"` - HeadersHeight int `json:"headersheight,omitempty"` - RescanHeight int `json:"rescanheight,omitempty"` -} - -// Input represents a transaction input. -type Input struct { - TxID string `json:"txid"` - Vout int `json:"vout"` -} - -// Output represents a transaction output. -type Output struct { - Address string `json:"address"` - Amount int `json:"amount"` -} - -// CreateTxReq represents a create transaction request. -type CreateTxReq struct { - Outputs []Output `json:"outputs"` - Inputs []Input `json:"inputs"` - IgnoreInputs []Input `json:"ignoreinputs"` - FeeRate int `json:"feerate"` - SendAll bool `json:"sendall"` - Password string `json:"password"` - Sign bool `json:"sign"` -} - -// CreateTxRes represents a create transaction response. -type CreateTxRes struct { - Hex string `json:"hex"` - Txid string `json:"txid"` - Fee int `json:"fee"` -} - -// BestBlockRes represents the best block response. -type BestBlockRes struct { - Hash string `json:"hash"` - Height int `json:"height"` -} - -// ListTransactionRes represents a list transaction response. -type ListTransactionRes struct { - Address string `json:"address,omitempty"` - Amount float64 `json:"amount"` - Category string `json:"category"` - Confirmations int64 `json:"confirmations"` - Height int64 `json:"height"` - Fee *float64 `json:"fee,omitempty"` - Time int64 `json:"time"` - TxID string `json:"txid"` - Vout uint32 `json:"vout"` -} - -// BirthdayStateRes represents the birthday state response. -type BirthdayStateRes struct { - Hash string `json:"hash"` - Height uint32 `json:"height"` - Time int64 `json:"time"` - SetFromHeight bool `json:"setfromheight"` - SetFromTime bool `json:"setfromtime"` -} - -// AddressesRes represents the addresses response. -type AddressesRes struct { - Used []string `json:"used"` - Unused []string `json:"unused"` - Index uint32 `json:"index"` -} - -// Config represents the wallet configuration. -type Config struct { - Name string `json:"name"` - // Allow getting unused addresses when not synced. - AllowUnsyncedAddrs bool `json:"unsyncedaddrs"` - Net string `json:"net"` - DataDir string `json:"datadir"` - // Only needed during creation. - Birthday int64 `json:"birthday"` - Pass string `json:"pass"` - Mnemonic string `json:"mnemonic"` - SeedPass string `json:"seedpass"` - // If the wallet existed before but the db was deleted to reduce - // storage, restore from the local encrypted seed using the provided - // password. Also works for watching only wallets with no password. - UseLocalSeed bool `json:"uselocalseed"` - // Only needed during watching only creation. - PubKey string `json:"pubkey"` -} - -// AddrFromExtKey represents the address from extended key request. -type AddrFromExtKey struct { - Key string `json:"key"` - Path string `json:"path"` - // Currently support types: P2PKH - AddrType string `json:"addrtype"` - UseChildBIP32Std bool `json:"usechildbip32std"` -} - -// CreateExtendedKeyReq represents the create extended key request. -// Note: Depth uses int instead of uint8 because gomobile doesn't handle uint8 well -// when generating Objective-C bindings (uint8 maps to 'byte' which conflicts with macOS types) -type CreateExtendedKeyReq struct { - Key string `json:"key"` - ParentKey string `json:"parentkey"` - ChainCode string `json:"chaincode"` - Network string `json:"network"` - Depth int `json:"depth"` - ChildN int `json:"childn"` - IsPrivate bool `json:"isprivate"` -} - -// ----------------------------------------------------------------------------- -// Internal types -// ----------------------------------------------------------------------------- - -type wallet struct { - *dcr.Wallet - log slog.Logger - - sync.WaitGroup - ctx context.Context - cancelCtx context.CancelFunc - - syncStatusMtx sync.RWMutex - syncStatusCode SyncStatusCode - targetHeight, cfiltersHeight, headersHeight, rescanHeight, numPeers int - rescanning, allowUnsyncedAddrs bool -} - -type parentLogger struct { - *slog.Backend - rotator *rotator.Rotator - lvl slog.Level -} - -func newParentLogger(rotator *rotator.Rotator, lvl slog.Level) *parentLogger { - return &parentLogger{ - Backend: slog.NewBackend(rotator), - rotator: rotator, - lvl: lvl, - } -} - -func newParentStdOutLogger(lvl slog.Level) *parentLogger { - backend := slog.NewBackend(os.Stdout) - return &parentLogger{ - Backend: backend, - lvl: lvl, - } -} - -func (pl *parentLogger) SubLogger(name string) slog.Logger { - logger := pl.Logger(name) - logger.SetLevel(pl.lvl) - return logger -} - -func (pl *parentLogger) Close() error { - if pl.rotator != nil { - return pl.rotator.Close() - } - return nil -} - -// ----------------------------------------------------------------------------- -// Helper functions -// ----------------------------------------------------------------------------- - -func loadedWallet(name string) (*wallet, bool) { - walletsMtx.RLock() - defer walletsMtx.RUnlock() - - w, ok := wallets[name] - if !ok { - logMtx.RLock() - if log != nil { - log.Debugf("attempted to use an unloaded wallet %q", name) - } - logMtx.RUnlock() - } - return w, ok -} - -// ----------------------------------------------------------------------------- -// Core Functions -// ----------------------------------------------------------------------------- - -// Initialize initializes the libwallet mobile library. -func Initialize(logDir, logLvl string) (string, error) { - walletsMtx.Lock() - defer walletsMtx.Unlock() - if initialized { - return "", errors.New("duplicate initialization") - } - - lvl, ok := slog.LevelFromString(logLvl) - if !ok { - return "", fmt.Errorf("unknown log level %q", logLvl) - } - - if logDir != "" { - logSpinner, err := assetlog.NewRotator(logDir, "dcrwallet.log") - if err != nil { - return "", fmt.Errorf("error initializing log rotator: %v", err) - } - - logBackend = newParentLogger(logSpinner, lvl) - err = dcr.InitGlobalLogging(logDir, logBackend, lvl) - if err != nil { - return "", fmt.Errorf("error initializing logger for external pkgs: %v", err) - } - } else { - logBackend = newParentStdOutLogger(lvl) - } - - logMtx.Lock() - log = logBackend.SubLogger("APP") - logMtx.Unlock() - - mainCtx, cancelMainCtx = context.WithCancel(context.Background()) - - initialized = true - return "libwallet mobile initialized", nil -} - -// Shutdown shuts down the libwallet mobile library. -func Shutdown() (string, error) { - walletsMtx.Lock() - defer walletsMtx.Unlock() - if !initialized { - return "", errors.New("not initialized") - } - - logMtx.RLock() - log.Debug("libwallet mobile shutting down") - logMtx.RUnlock() - - for _, w := range wallets { - if err := w.CloseWallet(); err != nil { - w.log.Errorf("close wallet error: %v", err) - } - } - wallets = make(map[string]*wallet) - - // Stop all remaining background processes and wait for them to stop. - cancelMainCtx() - wg.Wait() - - // Close the logger backend as the last step. - logMtx.Lock() - log.Debug("libwallet mobile shutdown") - logBackend.Close() - logBackend = nil - logMtx.Unlock() - - initialized = false - return "libwallet mobile shutdown", nil -} - -// ----------------------------------------------------------------------------- -// Wallet Management -// ----------------------------------------------------------------------------- - -// CreateWallet creates a new wallet with the given config JSON. -func CreateWallet(configJSON string) (string, error) { - walletsMtx.Lock() - defer walletsMtx.Unlock() - if !initialized { - return "", errors.New("libwallet is not initialized") - } - - var cfg Config - if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { - return "", fmt.Errorf("malformed config: %v", err) - } - - name := cfg.Name - if _, exists := wallets[name]; exists { - return "", fmt.Errorf("wallet already exists with name: %q", name) - } - - logger := logBackend.SubLogger(name) - params := dcr.CreateWalletParams{ - OpenWalletParams: dcr.OpenWalletParams{ - Net: cfg.Net, - DataDir: cfg.DataDir, - DbDriver: "bdb", // use badgerdb for mobile! - Logger: logger, - }, - Pass: []byte(cfg.Pass), - } - - var recoveryConfig *dcr.RecoveryCfg - if cfg.Mnemonic != "" { - var ( - seed []byte - birthday time.Time - seedType dcr.SeedType - err error - ) - nWords := len(strings.Fields(cfg.Mnemonic)) - switch nWords { - case 15: - seed, birthday, err = dexmnemonic.DecodeMnemonic(cfg.Mnemonic) - seedType = dcr.STFifteenWords - case 12: - seed, err = mnemonic.DecodeMnemonic(cfg.Mnemonic) - birthday = time.Unix(cfg.Birthday, 0) - seedType = dcr.STTwelveWords - case 24: - seed, err = mnemonic.DecodeMnemonic(cfg.Mnemonic) - birthday = time.Unix(cfg.Birthday, 0) - seedType = dcr.STTwentyFourWords - default: - return "", fmt.Errorf("unknown mnemonic format. expected 12, 15, or 24 words, got %d", nWords) - } - if err != nil { - return "", fmt.Errorf("unable to decode wallet mnemonic: %v", err) - } - recoveryConfig = &dcr.RecoveryCfg{ - Seed: seed, - SeedPass: []byte(cfg.SeedPass), - SeedType: seedType, - Birthday: birthday, - } - } - if cfg.UseLocalSeed { - recoveryConfig = &dcr.RecoveryCfg{ - UseLocalSeed: true, - } - } - - walletCtx, cancel := context.WithCancel(mainCtx) - - w, err := dcr.CreateWallet(walletCtx, params, recoveryConfig) - if err != nil { - cancel() - return "", err - } - - wallets[name] = &wallet{ - Wallet: w, - log: logger, - ctx: walletCtx, - cancelCtx: cancel, - allowUnsyncedAddrs: cfg.AllowUnsyncedAddrs, - } - return "wallet created", nil -} - -// CreateWatchOnlyWallet creates a watch-only wallet with the given config JSON. -func CreateWatchOnlyWallet(configJSON string) (string, error) { - walletsMtx.Lock() - defer walletsMtx.Unlock() - if !initialized { - return "", errors.New("libwallet is not initialized") - } - - var cfg Config - if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { - return "", fmt.Errorf("malformed config: %v", err) - } - - name := cfg.Name - if _, exists := wallets[name]; exists { - return "", fmt.Errorf("wallet already exists with name: %q", name) - } - - logger := logBackend.SubLogger(name) - params := dcr.CreateWalletParams{ - OpenWalletParams: dcr.OpenWalletParams{ - Net: cfg.Net, - DataDir: cfg.DataDir, - DbDriver: "bdb", - Logger: logger, - }, - } - - walletCtx, cancel := context.WithCancel(mainCtx) - - w, err := dcr.CreateWatchOnlyWallet(walletCtx, cfg.PubKey, params, cfg.UseLocalSeed) - if err != nil { - cancel() - return "", err - } - - wallets[name] = &wallet{ - Wallet: w, - log: logger, - ctx: walletCtx, - cancelCtx: cancel, - allowUnsyncedAddrs: cfg.AllowUnsyncedAddrs, - } - return "wallet created", nil -} - -// LoadWallet loads an existing wallet. -func LoadWallet(configJSON string) (string, error) { - walletsMtx.Lock() - defer walletsMtx.Unlock() - if !initialized { - return "", errors.New("libwallet is not initialized") - } - - var cfg Config - if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { - return "", fmt.Errorf("malformed config: %v", err) - } - - name := cfg.Name - if _, exists := wallets[name]; exists { - return "wallet already loaded", nil // not an error, already loaded - } - - logger := logBackend.SubLogger(name) - params := dcr.OpenWalletParams{ - Net: cfg.Net, - DataDir: cfg.DataDir, - DbDriver: "bdb", // use badgerdb for mobile! - Logger: logger, - } - - walletCtx, cancel := context.WithCancel(mainCtx) - - w, err := dcr.LoadWallet(walletCtx, params) - if err != nil { - cancel() - return "", err - } - - if err = w.OpenWallet(walletCtx); err != nil { - cancel() - return "", err - } - - wallets[name] = &wallet{ - Wallet: w, - log: logger, - ctx: walletCtx, - cancelCtx: cancel, - allowUnsyncedAddrs: cfg.AllowUnsyncedAddrs, - } - return fmt.Sprintf("wallet %q loaded", name), nil -} - -// CloseWallet closes a wallet. -func CloseWallet(name string) (string, error) { - walletsMtx.Lock() - defer walletsMtx.Unlock() - - w, exists := wallets[name] - if !exists { - return "", fmt.Errorf("wallet with name %q does not exist", name) - } - w.cancelCtx() - w.Wait() - if err := w.CloseWallet(); err != nil { - return "", fmt.Errorf("close wallet %q error: %v", name, err) - } - delete(wallets, name) - return fmt.Sprintf("wallet %q shutdown", name), nil -} - -// WalletSeed returns the wallet seed. -func WalletSeed(name, pass string) (string, error) { - w, ok := loadedWallet(name) - if !ok { - return "", fmt.Errorf("wallet with name %q not loaded", name) - } - - seed, err := w.DecryptSeed([]byte(pass)) - if err != nil { - return "", fmt.Errorf("w.DecryptSeed error: %v", err) - } - - return seed, nil -} - -// WalletBalance returns the wallet balance as JSON. -func WalletBalance(name string) (string, error) { - w, ok := loadedWallet(name) - if !ok { - return "", fmt.Errorf("wallet with name %q not loaded", name) - } - - const confs = 1 - bals, err := w.AccountBalances(w.ctx, confs) - if err != nil { - return "", fmt.Errorf("w.AccountBalances error: %v", err) - } - - balMap := map[string]int64{ - "confirmed": 0, - "unconfirmed": 0, - } - - for _, bal := range bals { - balMap["confirmed"] += int64(bal.Spendable) - balMap["unconfirmed"] += int64(bal.Total) - int64(bal.Spendable) - } - - balJSON, err := json.Marshal(balMap) - if err != nil { - return "", fmt.Errorf("marshal balMap error: %v", err) - } - - return string(balJSON), nil -} - -// ChangePassphrase changes the wallet passphrase. -func ChangePassphrase(name, oldPass, newPass string) (string, error) { - w, ok := loadedWallet(name) - if !ok { - return "", fmt.Errorf("wallet with name %q not loaded", name) - } - - oldPassBytes, newPassBytes := []byte(oldPass), []byte(newPass) - if err := w.MainWallet().ChangePrivatePassphrase(w.ctx, oldPassBytes, newPassBytes); err != nil { - return "", fmt.Errorf("w.ChangePrivatePassphrase error: %v", err) - } - - if err := w.ReEncryptSeed(oldPassBytes, newPassBytes); err != nil { - // Undo the passphrase change, since the re-encrypting the seed failed. - if undoErr := w.MainWallet().ChangePrivatePassphrase(w.ctx, newPassBytes, oldPassBytes); undoErr != nil { - logMtx.RLock() - log.Errorf("error undoing passphrase change: %v", undoErr) - logMtx.RUnlock() - } - return "", fmt.Errorf("w.ReEncryptSeed error: %v", err) - } - - return "passphrase changed", nil -} - -// ----------------------------------------------------------------------------- -// Address Functions -// ----------------------------------------------------------------------------- - -// CurrentReceiveAddress returns the current receive address. -func CurrentReceiveAddress(name string) (string, error) { - w, ok := loadedWallet(name) - if !ok { - return "", fmt.Errorf("wallet with name %q is not loaded", name) - } - - if !w.allowUnsyncedAddrs { - synced, _ := w.IsSynced(w.ctx) - if !synced { - return "", fmt.Errorf("currentReceiveAddress requested on an unsynced wallet (error code: %d)", ErrCodeNotSynced) - } - } - - addr, err := w.CurrentAddress(udb.DefaultAccountNum) - if err != nil { - return "", fmt.Errorf("w.CurrentAddress error: %v", err) - } - - return addr.String(), nil -} - -// NewExternalAddress creates a new external address. -func NewExternalAddress(name string) (string, error) { - w, ok := loadedWallet(name) - if !ok { - return "", fmt.Errorf("wallet with name %q is not loaded", name) - } - - if !w.allowUnsyncedAddrs { - synced, _ := w.IsSynced(w.ctx) - if !synced { - return "", fmt.Errorf("newExternalAddress requested on an unsynced wallet (error code: %d)", ErrCodeNotSynced) - } - } - - _, err := w.NewExternalAddress(w.ctx, udb.DefaultAccountNum) - if err != nil { - return "", fmt.Errorf("w.NewExternalAddress error: %v", err) - } - - // NewExternalAddress will take the current address before increasing - // the index. Get the current address after increasing the index. - addr, err := w.CurrentAddress(udb.DefaultAccountNum) - if err != nil { - return "", fmt.Errorf("w.CurrentAddress error: %v", err) - } - - return addr.String(), nil -} - -// SignMessage signs a message with the private key of the address. -func SignMessage(name, message, address, password string) (string, error) { - w, ok := loadedWallet(name) - if !ok { - return "", fmt.Errorf("wallet with name %q is not loaded", name) - } - - addr, err := stdaddr.DecodeAddress(address, w.MainWallet().ChainParams()) - if err != nil { - return "", fmt.Errorf("unable to decode address: %v", err) - } - - // Addresses must have an associated secp256k1 private key and therefore - // must be P2PK or P2PKH (P2SH is not allowed). - switch addr.(type) { - case *stdaddr.AddressPubKeyEcdsaSecp256k1V0: - case *stdaddr.AddressPubKeyHashEcdsaSecp256k1V0: - // Valid address types, proceed to sign. - default: - return "", errors.New("invalid address type: must be P2PK or P2PKH") - } - - if err := w.MainWallet().Unlock(w.ctx, []byte(password), nil); err != nil { - return "", fmt.Errorf("cannot unlock wallet: %v", err) - } - - sig, err := w.MainWallet().SignMessage(w.ctx, message, addr) - if err != nil { - return "", fmt.Errorf("unable to sign message: %v", err) - } - - sEnc := base64.StdEncoding.EncodeToString(sig) - return sEnc, nil -} - -// VerifyMessage verifies a signed message. -func VerifyMessage(name, message, address, sig string) (string, error) { - w, ok := loadedWallet(name) - if !ok { - return "", fmt.Errorf("wallet with name %q is not loaded", name) - } - - addr, err := stdaddr.DecodeAddress(address, w.MainWallet().ChainParams()) - if err != nil { - return "", fmt.Errorf("unable to decode address: %v", err) - } - - // Addresses must have an associated secp256k1 private key and therefore - // must be P2PK or P2PKH (P2SH is not allowed). - switch addr.(type) { - case *stdaddr.AddressPubKeyEcdsaSecp256k1V0: - case *stdaddr.AddressPubKeyHashEcdsaSecp256k1V0: - // Valid address types, proceed with verification. - default: - return "", errors.New("invalid address type: must be P2PK or P2PKH") - } - - sigBytes, err := base64.StdEncoding.DecodeString(sig) - if err != nil { - return "", fmt.Errorf("unable to decode signature: %v", err) - } - - ok, err = dcrwallet.VerifyMessage(message, addr, sigBytes, w.MainWallet().ChainParams()) - if err != nil { - return "", fmt.Errorf("unable to verify message: %v", err) - } - - return fmt.Sprintf("%v", ok), nil -} - -// Addresses returns the used and unused addresses. -func Addresses(name, nUsed, nUnused string) (string, error) { - w, ok := loadedWallet(name) - if !ok { - return "", fmt.Errorf("wallet with name %q is not loaded", name) - } - - nUsedVal, err := strconv.ParseUint(nUsed, 10, 32) - if err != nil { - return "", fmt.Errorf("number of used addresses is not a uint32: %v", err) - } - - nUnusedVal, err := strconv.ParseUint(nUnused, 10, 32) - if err != nil { - return "", fmt.Errorf("number of unused addresses is not a uint32: %v", err) - } - - used, unused, index, err := w.DefaultAccountAddresses(w.ctx, uint32(nUsedVal), uint32(nUnusedVal)) - if err != nil { - return "", fmt.Errorf("w.DefaultAccountAddresses error: %v", err) - } - - res := &AddressesRes{ - Used: used, - Unused: []string{}, - Index: index, - } - synced, _ := w.IsSynced(w.ctx) - if synced || w.allowUnsyncedAddrs { - res.Unused = unused - } - - b, err := json.Marshal(res) - if err != nil { - return "", fmt.Errorf("unable to marshal addresses: %v", err) - } - - return string(b), nil -} - -// DefaultPubkey returns the default account public key. -func DefaultPubkey(name string) (string, error) { - w, ok := loadedWallet(name) - if !ok { - return "", fmt.Errorf("wallet with name %q is not loaded", name) - } - - pubkey, err := w.AccountPubkey(w.ctx, defaultAccount) - if err != nil { - return "", fmt.Errorf("unable to get default pubkey: %v", err) - } - - return pubkey, nil -} - -// ValidateAddr validates an address. -func ValidateAddr(name, addr string) (string, error) { - w, exists := loadedWallet(name) - if !exists { - return "", fmt.Errorf("wallet with name %q does not exist", name) - } - validated, err := w.ValidateAddr(w.ctx, addr) - if err != nil { - return "", fmt.Errorf("unable to validate address: %v", err) - } - b, err := json.Marshal(validated) - if err != nil { - return "", fmt.Errorf("unable to marshal validate address: %v", err) - } - return string(b), nil -} - -// AddrFromExtendedKey returns an address from an extended key. -func AddrFromExtendedKey(addrFromExtKeyJSON string) (string, error) { - var fromExt AddrFromExtKey - if err := json.Unmarshal([]byte(addrFromExtKeyJSON), &fromExt); err != nil { - return "", fmt.Errorf("malformed create addr json: %v", err) - } - addr, err := dcr.AddrFromExtendedKey(fromExt.Key, fromExt.Path, fromExt.AddrType, fromExt.UseChildBIP32Std) - if err != nil { - return "", fmt.Errorf("unable to create address: %v", err) - } - return addr, nil -} - -// CreateExtendedKey creates an extended key. -func CreateExtendedKey(createExtKeyJSON string) (string, error) { - var createExt CreateExtendedKeyReq - if err := json.Unmarshal([]byte(createExtKeyJSON), &createExt); err != nil { - return "", fmt.Errorf("malformed create extended key json: %v", err) - } - extKey, err := dcr.CreateExtendedKey(createExt.Key, createExt.ParentKey, createExt.ChainCode, - createExt.Network, uint8(createExt.Depth), uint32(createExt.ChildN), createExt.IsPrivate) - if err != nil { - return "", fmt.Errorf("unable to create key: %v", err) - } - return extKey, nil -} - -// ----------------------------------------------------------------------------- -// Sync Functions -// ----------------------------------------------------------------------------- - -// SyncWallet starts syncing the wallet with the given peers. -func SyncWallet(name, peers string) (string, error) { - w, exists := loadedWallet(name) - if !exists { - return "", fmt.Errorf("wallet with name %q does not exist", name) - } - var peerList []string - for _, p := range strings.Split(peers, ",") { - if p = strings.TrimSpace(p); p != "" { - peerList = append(peerList, p) - } - } - ntfns := &spv.Notifications{ - Synced: func(sync bool) { - w.syncStatusMtx.Lock() - w.syncStatusCode = SSCComplete - w.syncStatusMtx.Unlock() - w.log.Debug("Sync completed.") - }, - PeerConnected: func(peerCount int32, addr string) { - w.syncStatusMtx.Lock() - w.numPeers = int(peerCount) - w.syncStatusMtx.Unlock() - w.log.Debugf("Connected to peer at %s. %d total peers.", addr, peerCount) - }, - PeerDisconnected: func(peerCount int32, addr string) { - w.syncStatusMtx.Lock() - w.numPeers = int(peerCount) - w.syncStatusMtx.Unlock() - w.log.Debugf("Disconnected from peer at %s. %d total peers.", addr, peerCount) - }, - FetchMissingCFiltersStarted: func() { - w.syncStatusMtx.Lock() - if w.rescanning { - w.syncStatusMtx.Unlock() - return - } - w.syncStatusCode = SSCFetchingCFilters - w.syncStatusMtx.Unlock() - w.log.Debug("Fetching missing cfilters started.") - }, - FetchMissingCFiltersProgress: func(startCFiltersHeight, endCFiltersHeight int32) { - w.syncStatusMtx.Lock() - w.cfiltersHeight = int(endCFiltersHeight) - w.syncStatusMtx.Unlock() - w.log.Debugf("Fetching cfilters from %d to %d.", startCFiltersHeight, endCFiltersHeight) - }, - FetchMissingCFiltersFinished: func() { - w.syncStatusMtx.Lock() - w.cfiltersHeight = w.targetHeight - w.syncStatusMtx.Unlock() - w.log.Debug("Finished fetching missing cfilters.") - }, - FetchHeadersStarted: func() { - w.syncStatusMtx.Lock() - if w.rescanning { - w.syncStatusMtx.Unlock() - return - } - w.syncStatusCode = SSCFetchingHeaders - w.syncStatusMtx.Unlock() - w.log.Debug("Fetching headers started.") - }, - FetchHeadersProgress: func(lastHeaderHeight int32, lastHeaderTime int64) { - w.syncStatusMtx.Lock() - w.headersHeight = int(lastHeaderHeight) - w.syncStatusMtx.Unlock() - w.log.Debugf("Fetching headers to %d.", lastHeaderHeight) - }, - FetchHeadersFinished: func() { - w.syncStatusMtx.Lock() - w.headersHeight = w.targetHeight - w.syncStatusMtx.Unlock() - w.log.Debug("Fetching headers finished.") - }, - DiscoverAddressesStarted: func() { - w.syncStatusMtx.Lock() - if w.rescanning { - w.syncStatusMtx.Unlock() - return - } - w.syncStatusCode = SSCDiscoveringAddrs - w.syncStatusMtx.Unlock() - w.log.Debug("Discover addresses started.") - }, - DiscoverAddressesFinished: func() { - w.log.Debug("Discover addresses finished.") - }, - RescanStarted: func() { - w.syncStatusMtx.Lock() - if w.rescanning { - w.syncStatusMtx.Unlock() - return - } - w.syncStatusCode = SSCRescanning - w.syncStatusMtx.Unlock() - w.log.Debug("Rescan started.") - }, - RescanProgress: func(rescannedThrough int32) { - w.syncStatusMtx.Lock() - w.rescanHeight = int(rescannedThrough) - w.syncStatusMtx.Unlock() - w.log.Debugf("Rescanned through block %d.", rescannedThrough) - }, - RescanFinished: func() { - w.syncStatusMtx.Lock() - w.rescanHeight = w.targetHeight - w.syncStatusMtx.Unlock() - w.log.Debug("Rescan finished.") - }, - } - if err := w.StartSync(w.ctx, ntfns, peerList...); err != nil { - return "", err - } - return "sync started", nil -} - -// SyncWalletStatus returns the sync status of the wallet. -func SyncWalletStatus(name string) (string, error) { - w, exists := loadedWallet(name) - if !exists { - return "", fmt.Errorf("wallet with name %q does not exist", name) - } - - w.syncStatusMtx.RLock() - var ssc, cfh, hh, rh, np = w.syncStatusCode, w.cfiltersHeight, w.headersHeight, w.rescanHeight, w.numPeers - w.syncStatusMtx.RUnlock() - - // Sometimes it appears we miss a notification during start up. This is - // a bandaid to put us as synced in that case. - synced, targetHeight := w.IsSynced(w.ctx) - w.syncStatusMtx.Lock() - if ssc != SSCComplete && synced && !w.rescanning { - ssc = SSCComplete - w.syncStatusCode = ssc - } - w.syncStatusMtx.Unlock() - - ss := &SyncStatusRes{ - SyncStatusCode: int(ssc), - SyncStatus: ssc.String(), - TargetHeight: int(targetHeight), - NumPeers: np, - } - switch ssc { - case SSCFetchingCFilters: - ss.CFiltersHeight = cfh - case SSCFetchingHeaders: - ss.HeadersHeight = hh - case SSCRescanning: - ss.RescanHeight = rh - } - b, err := json.Marshal(ss) - if err != nil { - return "", fmt.Errorf("unable to marshal sync status result: %v", err) - } - return string(b), nil -} - -// RescanFromHeight starts a rescan from the given height. -func RescanFromHeight(name, height string) (string, error) { - heightVal, err := strconv.ParseUint(height, 10, 32) - if err != nil { - return "", fmt.Errorf("height is not an uint32: %v", err) - } - w, exists := loadedWallet(name) - if !exists { - return "", fmt.Errorf("wallet with name %q does not exist", name) - } - synced, _ := w.IsSynced(w.ctx) - if !synced { - return "", fmt.Errorf("rescanFromHeight requested on an unsynced wallet (error code: %d)", ErrCodeNotSynced) - } - w.syncStatusMtx.Lock() - if w.rescanning { - w.syncStatusMtx.Unlock() - return "", fmt.Errorf("wallet %q already rescanning", name) - } - w.syncStatusCode = SSCRescanning - w.rescanning = true - w.rescanHeight = int(heightVal) - w.syncStatusMtx.Unlock() - w.Add(1) - go func() { - defer func() { - w.syncStatusMtx.Lock() - w.syncStatusCode = SSCComplete - w.rescanning = false - w.syncStatusMtx.Unlock() - w.Done() - }() - prog := make(chan dcrwallet.RescanProgress) - go func() { - w.RescanProgressFromHeight(w.ctx, int32(heightVal), prog) - }() - for { - select { - case p, open := <-prog: - if !open { - return - } - if p.Err != nil { - logMtx.RLock() - log.Errorf("rescan wallet %q error: %v", name, p.Err) - logMtx.RUnlock() - return - } - w.syncStatusMtx.Lock() - w.rescanHeight = int(p.ScannedThrough) - w.syncStatusMtx.Unlock() - case <-w.ctx.Done(): - return - } - } - }() - return fmt.Sprintf("rescan from height %d for wallet %q started", heightVal, name), nil -} - -// BirthState returns the birthday state of the wallet. -func BirthState(name string) (string, error) { - w, ok := loadedWallet(name) - if !ok { - return "", fmt.Errorf("wallet with name %q is not loaded", name) - } - - bs, err := w.MainWallet().BirthState(w.ctx) - if err != nil { - return "", fmt.Errorf("wallet.BirthState error: %v", err) - } - if bs == nil { - return "", fmt.Errorf("birth state is nil for wallet %q", name) - } - - bsRes := &BirthdayStateRes{ - Hash: bs.Hash.String(), - Height: bs.Height, - Time: bs.Time.Unix(), - SetFromHeight: bs.SetFromHeight, - SetFromTime: bs.SetFromTime, - } - b, err := json.Marshal(bsRes) - if err != nil { - return "", fmt.Errorf("unable to marshal birth state result: %v", err) - } - return string(b), nil -} - -// ----------------------------------------------------------------------------- -// Transaction Functions -// ----------------------------------------------------------------------------- - -// CreateTransaction creates a transaction. -func CreateTransaction(name, createTxReqJSON string) (string, error) { - w, exists := loadedWallet(name) - if !exists { - return "", fmt.Errorf("wallet with name %q does not exist", name) - } - var req CreateTxReq - if err := json.Unmarshal([]byte(createTxReqJSON), &req); err != nil { - return "", fmt.Errorf("malformed sign send request: %v", err) - } - - outputs := make([]*dcr.Output, len(req.Outputs)) - for i, out := range req.Outputs { - o := &dcr.Output{ - Address: out.Address, - Amount: uint64(out.Amount), - } - outputs[i] = o - } - - inputs := make([]*dcr.Input, len(req.Inputs)) - for i, in := range req.Inputs { - o := &dcr.Input{ - TxID: in.TxID, - Vout: uint32(in.Vout), - } - inputs[i] = o - } - - ignoreInputs := make([]*dcr.Input, len(req.IgnoreInputs)) - for i, in := range req.IgnoreInputs { - o := &dcr.Input{ - TxID: in.TxID, - Vout: uint32(in.Vout), - } - ignoreInputs[i] = o - } - - if req.Sign { - if err := w.MainWallet().Unlock(w.ctx, []byte(req.Password), nil); err != nil { - return "", fmt.Errorf("cannot unlock wallet: %v", err) - } - defer w.MainWallet().Lock() - } - - txBytes, txhash, fee, err := w.CreateTransaction(w.ctx, outputs, inputs, ignoreInputs, uint64(req.FeeRate), req.SendAll, req.Sign) - if err != nil { - return "", fmt.Errorf("unable to sign send transaction: %v", err) - } - res := &CreateTxRes{ - Hex: hex.EncodeToString(txBytes), - Txid: txhash.String(), - Fee: int(fee), - } - - b, err := json.Marshal(res) - if err != nil { - return "", fmt.Errorf("unable to marshal sign send transaction result: %v", err) - } - return string(b), nil -} - -// SendRawTransaction sends a raw transaction. -func SendRawTransaction(name, txHex string) (string, error) { - w, exists := loadedWallet(name) - if !exists { - return "", fmt.Errorf("wallet with name %q does not exist", name) - } - txHash, err := w.SendRawTransaction(w.ctx, txHex) - if err != nil { - return "", fmt.Errorf("unable to send raw transaction: %v", err) - } - return txHash.String(), nil -} - -// ListUnspents returns the unspent outputs. -func ListUnspents(name string) (string, error) { - w, exists := loadedWallet(name) - if !exists { - return "", fmt.Errorf("wallet with name %q does not exist", name) - } - res, err := w.MainWallet().ListUnspent(w.ctx, 1, math.MaxInt32, nil, defaultAccount) - if err != nil { - return "", fmt.Errorf("unable to get unspents: %v", err) - } - - type ListUnspentRes struct { - TxID string `json:"txid"` - Vout uint32 `json:"vout"` - Tree int8 `json:"tree"` - TxType int `json:"txtype"` - Address string `json:"address"` - Account string `json:"account"` - ScriptPubKey string `json:"scriptPubKey"` - RedeemScript string `json:"redeemScript,omitempty"` - Amount float64 `json:"amount"` - Confirmations int64 `json:"confirmations"` - Spendable bool `json:"spendable"` - IsChange bool `json:"ischange"` - } - - // Add is change to results. - unspentRes := make([]ListUnspentRes, len(res)) - for i, unspent := range res { - addr, err := stdaddr.DecodeAddress(unspent.Address, w.MainWallet().ChainParams()) - if err != nil { - return "", fmt.Errorf("unable to decode address: %v", err) - } - - ka, err := w.MainWallet().KnownAddress(w.ctx, addr) - if err != nil { - return "", fmt.Errorf("unspent address is not known: %v", err) - } - - isChange := false - if ka, ok := ka.(dcrwallet.BIP0044Address); ok { - _, branch, _ := ka.Path() - isChange = branch == 1 - } - unspentRes[i] = ListUnspentRes{ - TxID: unspent.TxID, - Vout: unspent.Vout, - Tree: unspent.Tree, - TxType: unspent.TxType, - Address: unspent.Address, - Account: unspent.Account, - ScriptPubKey: unspent.ScriptPubKey, - RedeemScript: unspent.RedeemScript, - Amount: unspent.Amount, - Confirmations: unspent.Confirmations, - Spendable: unspent.Spendable, - IsChange: isChange, - } - } - b, err := json.Marshal(unspentRes) - if err != nil { - return "", fmt.Errorf("unable to marshal list unspents result: %v", err) - } - return string(b), nil -} - -// EstimateFee estimates the fee for a transaction. -func EstimateFee(name, nBlocks string) (string, error) { - w, exists := loadedWallet(name) - if !exists { - return "", fmt.Errorf("wallet with name %q does not exist", name) - } - nBlocksVal, err := strconv.ParseUint(nBlocks, 10, 64) - if err != nil { - return "", fmt.Errorf("number of blocks is not a uint64: %v", err) - } - txFee, err := w.FetchFeeFromOracle(w.ctx, nBlocksVal) - if err != nil { - return "", fmt.Errorf("unable to get fee from oracle: %v", err) - } - return fmt.Sprintf("%d", uint64(txFee*1e8)), nil -} - -// ListTransactions lists transactions. -func ListTransactions(name, from, count string) (string, error) { - w, exists := loadedWallet(name) - if !exists { - return "", fmt.Errorf("wallet with name %q does not exist", name) - } - fromVal, err := strconv.ParseInt(from, 10, 32) - if err != nil { - return "", fmt.Errorf("from is not an int: %v", err) - } - countVal, err := strconv.ParseInt(count, 10, 32) - if err != nil { - return "", fmt.Errorf("count is not an int: %v", err) - } - res, err := w.MainWallet().ListTransactions(w.ctx, int(fromVal), int(countVal)) - if err != nil { - return "", fmt.Errorf("unable to get transactions: %v", err) - } - _, blockHeight := w.MainWallet().MainChainTip(w.ctx) - ltRes := make([]*ListTransactionRes, len(res)) - for i, ltw := range res { - // Use earliest of receive time or block time if the transaction is mined. - receiveTime := ltw.TimeReceived - if ltw.BlockTime != 0 && ltw.BlockTime < ltw.TimeReceived { - receiveTime = ltw.BlockTime - } - - var height int64 - if ltw.Confirmations > 0 { - height = int64(blockHeight) - ltw.Confirmations + 1 - } - - lt := &ListTransactionRes{ - Address: ltw.Address, - Amount: ltw.Amount, - Category: ltw.Category, - Confirmations: ltw.Confirmations, - Height: height, - Fee: ltw.Fee, - Time: receiveTime, - TxID: ltw.TxID, - Vout: ltw.Vout, - } - ltRes[i] = lt - } - b, err := json.Marshal(ltRes) - if err != nil { - return "", fmt.Errorf("unable to marshal list transactions result: %v", err) - } - return string(b), nil -} - -// BestBlock returns the best block. -func BestBlock(name string) (string, error) { - w, exists := loadedWallet(name) - if !exists { - return "", fmt.Errorf("wallet with name %q does not exist", name) - } - blockHash, blockHeight := w.MainWallet().MainChainTip(w.ctx) - res := &BestBlockRes{ - Hash: blockHash.String(), - Height: int(blockHeight), - } - b, err := json.Marshal(res) - if err != nil { - return "", fmt.Errorf("unable to marshal best block result: %v", err) - } - return string(b), nil -} - -// DecodeTx decodes a transaction. -func DecodeTx(name, txHex string) (string, error) { - w, exists := loadedWallet(name) - if !exists { - return "", fmt.Errorf("wallet with name %q does not exist", name) - } - decoded, err := w.DecodeTx(txHex) - if err != nil { - return "", fmt.Errorf("unable to decode tx: %v", err) - } - b, err := json.Marshal(decoded) - if err != nil { - return "", fmt.Errorf("unable to marshal decoded tx: %v", err) - } - return string(b), nil -} - -// GetTxn gets transactions by hashes. -func GetTxn(name, hashesJSON string) (string, error) { - w, exists := loadedWallet(name) - if !exists { - return "", fmt.Errorf("wallet with name %q does not exist", name) - } - var txIDs []string - if err := json.Unmarshal([]byte(hashesJSON), &txIDs); err != nil { - return "", fmt.Errorf("unable to unmarshal hashes: %v", err) - } - txHashes := make([]*chainhash.Hash, len(txIDs)) - for i, txID := range txIDs { - txHash, err := chainhash.NewHashFromStr(txID) - if err != nil { - return "", fmt.Errorf("unable to create tx hash: %v", err) - } - txHashes[i] = txHash - } - hexes, err := w.GetTxn(w.ctx, txHashes) - if err != nil { - return "", fmt.Errorf("unable to get txn: %v", err) - } - b, err := json.Marshal(hexes) - if err != nil { - return "", fmt.Errorf("unable to marshal txn: %v", err) - } - return string(b), nil -} - -// AddSigs adds signatures to a transaction. -func AddSigs(name, txHex, sigScriptsJSON string) (string, error) { - w, exists := loadedWallet(name) - if !exists { - return "", fmt.Errorf("wallet with name %q does not exist", name) - } - var sigScripts []string - if err := json.Unmarshal([]byte(sigScriptsJSON), &sigScripts); err != nil { - return "", fmt.Errorf("unable to unmarshal sig scripts: %v", err) - } - signedHex, err := w.AddSigs(txHex, sigScripts) - if err != nil { - return "", fmt.Errorf("unable sign tx: %v", err) - } - return signedHex, nil -} From 19c5f33a8811553e6c7b3aae244739f9df630115 Mon Sep 17 00:00:00 2001 From: Jusitn Do Date: Tue, 6 Jan 2026 16:53:12 +0700 Subject: [PATCH 5/6] update scrip build ios --- build_gomobile_ios.sh | 44 +++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/build_gomobile_ios.sh b/build_gomobile_ios.sh index 2b27779..ee25a48 100755 --- a/build_gomobile_ios.sh +++ b/build_gomobile_ios.sh @@ -44,7 +44,7 @@ set -o pipefail # Pipe failures propagate SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$SCRIPT_DIR" BUILD_DIR="$PROJECT_DIR/build" -MOBILE_DIR="$PROJECT_DIR/mobile" +MOBILE_DIR="$PROJECT_DIR/gomobile" FRAMEWORK_NAME="Libwallet" XCFRAMEWORK_NAME="${FRAMEWORK_NAME}.xcframework" @@ -201,21 +201,21 @@ check_gomobile() { fi } -# Verify mobile package exists +# Verify gomobile package exists verify_mobile_package() { - log_step "Verifying mobile package..." + log_step "Verifying gomobile package..." if [ ! -d "$MOBILE_DIR" ]; then - log_error "Mobile package directory not found: $MOBILE_DIR" + log_error "Gomobile package directory not found: $MOBILE_DIR" exit 1 fi - if [ ! -f "$MOBILE_DIR/mobile.go" ]; then - log_error "mobile.go not found in $MOBILE_DIR" + if [ ! -f "$MOBILE_DIR/gomobile.go" ]; then + log_error "gomobile.go not found in $MOBILE_DIR" exit 1 fi - log_info "✓ Mobile package found at $MOBILE_DIR" + log_info "✓ Gomobile package found at $MOBILE_DIR" } # Validate XCFramework structure @@ -323,19 +323,19 @@ build_device() { cd "$PROJECT_DIR" - log_info "Running: gomobile bind -target=$target $ldflags $verbose_flag -o $BUILD_DIR/$XCFRAMEWORK_NAME ./mobile" + log_info "Running: gomobile bind -target=$target $ldflags $verbose_flag -o $BUILD_DIR/$XCFRAMEWORK_NAME ./gomobile" if [ "$OPTIMIZE" = true ]; then if [ "$VERBOSE" = true ]; then - gomobile bind -target="$target" -ldflags="-s -w" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + gomobile bind -target="$target" -ldflags="-s -w" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./gomobile || return 1 else - gomobile bind -target="$target" -ldflags="-s -w" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + gomobile bind -target="$target" -ldflags="-s -w" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./gomobile || return 1 fi else if [ "$VERBOSE" = true ]; then - gomobile bind -target="$target" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + gomobile bind -target="$target" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./gomobile || return 1 else - gomobile bind -target="$target" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + gomobile bind -target="$target" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./gomobile || return 1 fi fi @@ -366,19 +366,19 @@ build_simulator() { cd "$PROJECT_DIR" - log_info "Running: gomobile bind -target=$target $ldflags $verbose_flag -o $BUILD_DIR/$XCFRAMEWORK_NAME ./mobile" + log_info "Running: gomobile bind -target=$target $ldflags $verbose_flag -o $BUILD_DIR/$XCFRAMEWORK_NAME ./gomobile" if [ "$OPTIMIZE" = true ]; then if [ "$VERBOSE" = true ]; then - gomobile bind -target="$target" -ldflags="-s -w" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + gomobile bind -target="$target" -ldflags="-s -w" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./gomobile || return 1 else - gomobile bind -target="$target" -ldflags="-s -w" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + gomobile bind -target="$target" -ldflags="-s -w" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./gomobile || return 1 fi else if [ "$VERBOSE" = true ]; then - gomobile bind -target="$target" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + gomobile bind -target="$target" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./gomobile || return 1 else - gomobile bind -target="$target" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + gomobile bind -target="$target" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./gomobile || return 1 fi fi @@ -409,19 +409,19 @@ build_all() { cd "$PROJECT_DIR" - log_info "Running: gomobile bind -target=$target $ldflags $verbose_flag -o $BUILD_DIR/$XCFRAMEWORK_NAME ./mobile" + log_info "Running: gomobile bind -target=$target $ldflags $verbose_flag -o $BUILD_DIR/$XCFRAMEWORK_NAME ./gomobile" if [ "$OPTIMIZE" = true ]; then if [ "$VERBOSE" = true ]; then - gomobile bind -target="$target" -ldflags="-s -w" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + gomobile bind -target="$target" -ldflags="-s -w" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./gomobile || return 1 else - gomobile bind -target="$target" -ldflags="-s -w" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + gomobile bind -target="$target" -ldflags="-s -w" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./gomobile || return 1 fi else if [ "$VERBOSE" = true ]; then - gomobile bind -target="$target" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + gomobile bind -target="$target" -v -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./gomobile || return 1 else - gomobile bind -target="$target" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./mobile || return 1 + gomobile bind -target="$target" -o "$BUILD_DIR/$XCFRAMEWORK_NAME" ./gomobile || return 1 fi fi From 1b07460e3e9eb64ceda3d2ca13ba2de4b0d5a059 Mon Sep 17 00:00:00 2001 From: Jusitn Do Date: Sat, 10 Jan 2026 15:11:02 +0700 Subject: [PATCH 6/6] fix error handling and messages in gomobile --- gomobile/gomobile.go | 4 ++-- gomobile/transactions.go | 8 ++++---- gomobile/walletloader.go | 6 ++++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/gomobile/gomobile.go b/gomobile/gomobile.go index 07c98aa..8760670 100644 --- a/gomobile/gomobile.go +++ b/gomobile/gomobile.go @@ -2,8 +2,8 @@ // This package is designed to be compiled with gomobile for iOS and Android. // // Build examples: -// gomobile bind -target=android -androidapi=23 -o ./build/libwallet.aar ./mobile -// gomobile bind -target=ios -o ./build/Libwallet.xcframework ./mobile +// gomobile bind -target=android -androidapi=23 -o ./build/libwallet.aar ./gomobile +// gomobile bind -target=ios -o ./build/Libwallet.xcframework ./gomobile package libwallet import ( diff --git a/gomobile/transactions.go b/gomobile/transactions.go index d4be76f..6a60266 100644 --- a/gomobile/transactions.go +++ b/gomobile/transactions.go @@ -25,7 +25,7 @@ func CreateTransaction(name, createTxReqJSON string) (string, error) { var req CreateTxReq if err := json.Unmarshal([]byte(createTxReqJSON), &req); err != nil { - return "", fmt.Errorf("malformed sign send request: %v", err) + return "", fmt.Errorf("malformed create transaction request: %v", err) } outputs := make([]*dcr.Output, len(req.Outputs)) @@ -52,7 +52,7 @@ func CreateTransaction(name, createTxReqJSON string) (string, error) { txBytes, txhash, fee, err := w.CreateTransaction(w.ctx, outputs, inputs, ignoreInputs, uint64(req.FeeRate), req.SendAll, req.Sign) if err != nil { - return "", fmt.Errorf("unable to sign send transaction: %v", err) + return "", fmt.Errorf("unable to create transaction: %v", err) } res := &CreateTxRes{ @@ -63,7 +63,7 @@ func CreateTransaction(name, createTxReqJSON string) (string, error) { b, err := json.Marshal(res) if err != nil { - return "", fmt.Errorf("unable to marshal sign send transaction result: %v", err) + return "", fmt.Errorf("unable to marshal create transaction result: %v", err) } return string(b), nil } @@ -301,7 +301,7 @@ func AddSigs(name, txHex, sigScriptsJSON string) (string, error) { signedHex, err := w.AddSigs(txHex, sigScripts) if err != nil { - return "", fmt.Errorf("unable sign tx: %v", err) + return "", fmt.Errorf("unable to sign tx: %v", err) } return signedHex, nil } diff --git a/gomobile/walletloader.go b/gomobile/walletloader.go index 4330ff2..6cab2e6 100644 --- a/gomobile/walletloader.go +++ b/gomobile/walletloader.go @@ -208,10 +208,11 @@ func CloseWallet(name string) (string, error) { } w.cancelCtx() w.Wait() - if err := w.CloseWallet(); err != nil { + err := w.CloseWallet() + delete(wallets, name) // Always remove to prevent leaked references + if err != nil { return "", fmt.Errorf("close wallet %q error: %v", name, err) } - delete(wallets, name) return fmt.Sprintf("wallet %q shutdown", name), nil } @@ -279,6 +280,7 @@ func ChangePassphrase(name, oldPass, newPass string) (string, error) { logMtx.RLock() log.Errorf("error undoing passphrase change: %v", undoErr) logMtx.RUnlock() + return "", fmt.Errorf("critical failure: seed re-encryption failed (%v) and passphrase rollback failed (%v); wallet may be in an inconsistent state", err, undoErr) } return "", fmt.Errorf("w.ReEncryptSeed error: %v", err) }