// Copyright (c) 2017-2026 Lethean (https://lt.hn) // // Licensed under the European Union Public Licence (EUPL) version 1.2. // You may obtain a copy of the licence at: // // https://joinup.ec.europa.eu/software/page/eupl/licence-eupl // // SPDX-License-Identifier: EUPL-1.2 package wallet import ( "cmp" "fmt" "slices" "strconv" coreerr "dappco.re/go/core/log" "dappco.re/go/core/blockchain/chain" "dappco.re/go/core/blockchain/rpc" "dappco.re/go/core/blockchain/types" "dappco.re/go/core/blockchain/wire" store "dappco.re/go/core/store" ) const ( scanHeightKey = "scan_height" ) // Wallet ties together scanning, building, and sending. type Wallet struct { account *Account store *store.Store chain *chain.Chain client *rpc.Client scanner Scanner signer Signer ringSelector RingSelector builder Builder } // NewWallet creates a wallet with v1 defaults. func NewWallet(account *Account, s *store.Store, c *chain.Chain, client *rpc.Client) *Wallet { scanner := NewV1Scanner(account) signer := &NLSAGSigner{} var ringSelector RingSelector var builder Builder if client != nil { ringSelector = NewRPCRingSelector(client) builder = NewV1Builder(signer, ringSelector) } return &Wallet{ account: account, store: s, chain: c, client: client, scanner: scanner, signer: signer, ringSelector: ringSelector, builder: builder, } } // Sync scans blocks from the last checkpoint to the chain tip. func (w *Wallet) Sync() error { lastScanned := w.loadScanHeight() chainHeight, err := w.chain.Height() if err != nil { return coreerr.E("Wallet.Sync", "wallet: chain height", err) } for h := lastScanned; h < chainHeight; h++ { blk, _, err := w.chain.GetBlockByHeight(h) if err != nil { return coreerr.E("Wallet.Sync", fmt.Sprintf("wallet: get block %d", h), err) } // Scan miner tx. if err := w.scanTx(&blk.MinerTx, h); err != nil { return err } // Scan regular transactions. for _, txHash := range blk.TxHashes { tx, _, err := w.chain.GetTransaction(txHash) if err != nil { continue // skip missing txs } if err := w.scanTx(tx, h); err != nil { return err } } w.saveScanHeight(h + 1) } return nil } func (w *Wallet) scanTx(tx *types.Transaction, blockHeight uint64) error { txHash := wire.TransactionHash(tx) extra, err := ParseTxExtra(tx.Extra) if err != nil { return nil // skip unparseable extras } // Detect owned outputs. transfers, err := w.scanner.ScanTransaction(tx, txHash, blockHeight, extra) if err != nil { return nil } for i := range transfers { if err := putTransfer(w.store, &transfers[i]); err != nil { return coreerr.E("Wallet.scanTx", "wallet: store transfer", err) } } // Check key images for spend detection. for _, vin := range tx.Vin { var keyImage types.KeyImage switch v := vin.(type) { case types.TxInputToKey: keyImage = v.KeyImage case types.TxInputHTLC: keyImage = v.KeyImage default: continue } // Try to mark any matching transfer as spent. tr, err := getTransfer(w.store, keyImage) if err != nil { continue // not our transfer } if !tr.Spent { markTransferSpent(w.store, keyImage, blockHeight) } } return nil } // Balance returns confirmed (spendable) and locked amounts. func (w *Wallet) Balance() (confirmed, locked uint64, err error) { chainHeight, err := w.chain.Height() if err != nil { return 0, 0, err } transfers, err := listTransfers(w.store) if err != nil { return 0, 0, err } for _, tr := range transfers { if tr.Spent { continue } if tr.IsSpendable(chainHeight, false) { confirmed += tr.Amount } else { locked += tr.Amount } } return confirmed, locked, nil } // Send constructs and submits a transaction. func (w *Wallet) Send(destinations []Destination, fee uint64) (*types.Transaction, error) { if w.builder == nil || w.client == nil { return nil, coreerr.E("Wallet.Send", "wallet: no RPC client configured", nil) } chainHeight, err := w.chain.Height() if err != nil { return nil, err } var destSum uint64 for _, d := range destinations { destSum += d.Amount } needed := destSum + fee // Coin selection: largest-first greedy. transfers, err := listTransfers(w.store) if err != nil { return nil, err } // Filter spendable and sort by amount descending. var spendable []Transfer for _, tr := range transfers { if tr.IsSpendable(chainHeight, false) { spendable = append(spendable, tr) } } slices.SortFunc(spendable, func(a, b Transfer) int { return cmp.Compare(b.Amount, a.Amount) // descending }) var selected []Transfer var selectedSum uint64 for _, tr := range spendable { selected = append(selected, tr) selectedSum += tr.Amount if selectedSum >= needed { break } } if selectedSum < needed { return nil, coreerr.E("Wallet.Send", fmt.Sprintf("wallet: insufficient balance: have %d, need %d", selectedSum, needed), nil) } req := &BuildRequest{ Sources: selected, Destinations: destinations, Fee: fee, SenderAddress: w.account.Address(), } tx, err := w.builder.Build(req) if err != nil { return nil, err } blob, err := SerializeTransaction(tx) if err != nil { return nil, err } if err := w.client.SendRawTransaction(blob); err != nil { return nil, coreerr.E("Wallet.Send", "wallet: submit tx", err) } // Optimistically mark sources as spent. for _, src := range selected { markTransferSpent(w.store, src.KeyImage, chainHeight) } return tx, nil } // Transfers returns all tracked transfers. func (w *Wallet) Transfers() ([]Transfer, error) { return listTransfers(w.store) } func (w *Wallet) loadScanHeight() uint64 { val, err := w.store.Get(groupAccount, scanHeightKey) if err != nil { return 0 } h, _ := strconv.ParseUint(val, 10, 64) return h } func (w *Wallet) saveScanHeight(h uint64) { w.store.Set(groupAccount, scanHeightKey, strconv.FormatUint(h, 10)) }