From b8e0914ba9f088cf6dcdb563f0df1ffa7064cd66 Mon Sep 17 00:00:00 2001 From: Louis Singer <41042567+louisinger@users.noreply.github.com> Date: Tue, 20 Feb 2024 17:10:18 +0100 Subject: [PATCH] [Client] Add vtxo expiration details to balance & Fix coin selection * add expiry details in balance command * coin selection: sort vtxos by olderFirst * rename type * balance: add next expiration * add next expiration in offchain_balance json * print duration in nextExpiration * fix dust coin selection * refactor sort --- client/balance.go | 95 ++++++++++-- client/client.go | 31 +++- client/common.go | 137 +++++++++++++----- client/explorer.go | 34 ++++- client/init.go | 5 +- client/redeem.go | 56 ++----- client/send.go | 4 +- client/signer.go | 5 +- client/unilateral_redeem.go | 126 +++++++++++----- .../tx-builder/covenant/builder.go | 25 ++++ 10 files changed, 378 insertions(+), 140 deletions(-) diff --git a/client/balance.go b/client/balance.go index 5e45359..7df990f 100644 --- a/client/balance.go +++ b/client/balance.go @@ -1,18 +1,31 @@ package main import ( + "fmt" + "math" "sync" + "time" "github.com/urfave/cli/v2" ) +var expiryDetailsFlag = cli.BoolFlag{ + Name: "expiry-details", + Usage: "show cumulative balance by expiry time", + Value: false, + Required: false, +} + var balanceCommand = cli.Command{ Name: "balance", Usage: "Print balance of the Ark wallet", Action: balanceAction, + Flags: []cli.Flag{&expiryDetailsFlag}, } func balanceAction(ctx *cli.Context) error { + expiryDetails := ctx.Bool("expiry-details") + client, cancel, err := getClientFromState(ctx) if err != nil { return err @@ -30,26 +43,30 @@ func balanceAction(ctx *cli.Context) error { chRes := make(chan balanceRes, 2) go func() { defer wg.Done() - balance, err := getOffchainBalance(ctx, client, offchainAddr) + explorer := NewExplorer() + balance, amountByExpiration, err := getOffchainBalance(ctx, explorer, client, offchainAddr, true) if err != nil { - chRes <- balanceRes{0, 0, err} + chRes <- balanceRes{0, 0, nil, err} return } - chRes <- balanceRes{balance, 0, nil} + + chRes <- balanceRes{balance, 0, amountByExpiration, nil} }() go func() { defer wg.Done() balance, err := getOnchainBalance(onchainAddr) if err != nil { - chRes <- balanceRes{0, 0, err} + chRes <- balanceRes{0, 0, nil, err} return } - chRes <- balanceRes{0, balance, nil} + chRes <- balanceRes{0, balance, nil, nil} }() wg.Wait() + details := make([]map[string]interface{}, 0) offchainBalance, onchainBalance := uint64(0), uint64(0) + nextExpiration := int64(0) count := 0 for res := range chRes { if res.err != nil { @@ -61,20 +78,74 @@ func balanceAction(ctx *cli.Context) error { if res.onchainBalance > 0 { onchainBalance = res.onchainBalance } + if res.amountByExpiration != nil { + for timestamp, amount := range res.amountByExpiration { + if nextExpiration == 0 || timestamp < nextExpiration { + nextExpiration = timestamp + } + + fancyTime := time.Unix(timestamp, 0).Format("2006-01-02 15:04:05") + details = append( + details, + map[string]interface{}{ + "expiry_time": fancyTime, + "amount": amount, + }, + ) + } + } + count++ if count == 2 { break } } - return printJSON(map[string]interface{}{ - "offchain_balance": offchainBalance, - "onchain_balance": onchainBalance, - }) + response := make(map[string]interface{}) + response["onchain_balance"] = onchainBalance + + offchainBalanceJSON := map[string]interface{}{ + "total": offchainBalance, + } + + fancyTimeExpiration := "" + if nextExpiration != 0 { + t := time.Unix(nextExpiration, 0) + if t.Before(time.Now().Add(48 * time.Hour)) { + // print the duration instead of the absolute time + until := time.Until(t) + seconds := math.Abs(until.Seconds()) + minutes := math.Abs(until.Minutes()) + hours := math.Abs(until.Hours()) + + if hours < 1 { + if minutes < 1 { + fancyTimeExpiration = fmt.Sprintf("%d seconds", int(seconds)) + } else { + fancyTimeExpiration = fmt.Sprintf("%d minutes", int(minutes)) + } + } else { + fancyTimeExpiration = fmt.Sprintf("%d hours", int(hours)) + } + } else { + fancyTimeExpiration = t.Format("2006-01-02 15:04:05") + } + + offchainBalanceJSON["next_expiration"] = fancyTimeExpiration + } + + if expiryDetails { + offchainBalanceJSON["details"] = details + } + + response["offchain_balance"] = offchainBalanceJSON + + return printJSON(response) } type balanceRes struct { - offchainBalance uint64 - onchainBalance uint64 - err error + offchainBalance uint64 + onchainBalance uint64 + amountByExpiration map[int64]uint64 + err error } diff --git a/client/client.go b/client/client.go index 2c656fa..939d3ff 100644 --- a/client/client.go +++ b/client/client.go @@ -3,6 +3,7 @@ package main import ( "fmt" "strings" + "time" arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1" "github.com/urfave/cli/v2" @@ -16,10 +17,15 @@ type vtxo struct { txid string vout uint32 poolTxid string + expireAt *time.Time } func getVtxos( - ctx *cli.Context, client arkv1.ArkServiceClient, addr string, + ctx *cli.Context, + explorer Explorer, + client arkv1.ArkServiceClient, + addr string, + withExpiration bool, ) ([]vtxo, error) { response, err := client.ListVtxos(ctx.Context, &arkv1.ListVtxosRequest{ Address: addr, @@ -38,6 +44,29 @@ func getVtxos( }) } + if !withExpiration { + return vtxos, nil + } + + redeemBranches, err := getRedeemBranches(ctx, explorer, client, vtxos) + if err != nil { + return nil, err + } + + for vtxoTxid, branch := range redeemBranches { + expiration, err := branch.ExpireAt() + if err != nil { + return nil, err + } + + for i, vtxo := range vtxos { + if vtxo.txid == vtxoTxid { + vtxos[i].expireAt = expiration + break + } + } + } + return vtxos, nil } diff --git a/client/common.go b/client/common.go index 1ae7bb3..2636e18 100644 --- a/client/common.go +++ b/client/common.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "sort" "syscall" "time" @@ -150,6 +151,15 @@ func coinSelect(vtxos []vtxo, amount uint64) ([]vtxo, uint64, error) { notSelected := make([]vtxo, 0) selectedAmount := uint64(0) + // sort vtxos by expiration (older first) + sort.SliceStable(vtxos, func(i, j int) bool { + if vtxos[i].expireAt == nil || vtxos[j].expireAt == nil { + return false + } + + return vtxos[i].expireAt.Before(*vtxos[j].expireAt) + }) + for _, vtxo := range vtxos { if selectedAmount >= amount { notSelected = append(notSelected, vtxo) @@ -177,17 +187,30 @@ func coinSelect(vtxos []vtxo, amount uint64) ([]vtxo, uint64, error) { } func getOffchainBalance( - ctx *cli.Context, client arkv1.ArkServiceClient, addr string, -) (uint64, error) { - vtxos, err := getVtxos(ctx, client, addr) + ctx *cli.Context, explorer Explorer, client arkv1.ArkServiceClient, addr string, withExpiration bool, +) (uint64, map[int64]uint64, error) { + amountByExpiration := make(map[int64]uint64, 0) + + vtxos, err := getVtxos(ctx, explorer, client, addr, withExpiration) if err != nil { - return 0, err + return 0, nil, err } var balance uint64 for _, vtxo := range vtxos { balance += vtxo.amount + + if withExpiration { + expiration := vtxo.expireAt.Unix() + + if _, ok := amountByExpiration[expiration]; !ok { + amountByExpiration[expiration] = 0 + } + + amountByExpiration[expiration] += vtxo.amount + } } - return balance, nil + + return balance, amountByExpiration, nil } type utxo struct { @@ -198,11 +221,7 @@ type utxo struct { } func getOnchainUtxos(addr string) ([]utxo, error) { - _, net, err := getNetwork() - if err != nil { - return nil, err - } - + _, net := getNetwork() baseUrl := explorerUrl[net.Name] resp, err := http.Get(fmt.Sprintf("%s/address/%s/utxo", baseUrl, addr)) if err != nil { @@ -230,11 +249,7 @@ func getOnchainBalance(addr string) (uint64, error) { return 0, err } - _, net, err := getNetwork() - if err != nil { - return 0, err - } - + _, net := getNetwork() balance := uint64(0) for _, p := range payload { if p.Asset != net.AssetID { @@ -245,36 +260,43 @@ func getOnchainBalance(addr string) (uint64, error) { return balance, nil } -func getTxHex(txid string) (string, error) { - _, net, err := getNetwork() - if err != nil { - return "", err - } - +func getTxBlocktime(txid string) (confirmed bool, blocktime int64, err error) { + _, net := getNetwork() baseUrl := explorerUrl[net.Name] - resp, err := http.Get(fmt.Sprintf("%s/tx/%s/hex", baseUrl, txid)) + resp, err := http.Get(fmt.Sprintf("%s/tx/%s", baseUrl, txid)) if err != nil { - return "", err + return false, 0, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return "", err + return false, 0, err } if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf(string(body)) + return false, 0, fmt.Errorf(string(body)) } - return string(body), nil + var tx struct { + Status struct { + Confirmed bool `json:"confirmed"` + Blocktime int64 `json:"block_time"` + } `json:"status"` + } + if err := json.Unmarshal(body, &tx); err != nil { + return false, 0, err + } + + if !tx.Status.Confirmed { + return false, -1, nil + } + + return true, tx.Status.Blocktime, nil + } func broadcast(txHex string) (string, error) { - _, net, err := getNetwork() - if err != nil { - return "", err - } - + _, net := getNetwork() body := bytes.NewBuffer([]byte(txHex)) baseUrl := explorerUrl[net.Name] @@ -295,20 +317,20 @@ func broadcast(txHex string) (string, error) { return string(bodyResponse), nil } -func getNetwork() (*common.Network, *network.Network, error) { +func getNetwork() (*common.Network, *network.Network) { state, err := getState() if err != nil { - return nil, nil, err + return &common.TestNet, &network.Testnet } net, ok := state["network"] if !ok { - return &common.MainNet, &network.Liquid, nil + return &common.MainNet, &network.Liquid } if net == "testnet" { - return &common.TestNet, &network.Testnet, nil + return &common.TestNet, &network.Testnet } - return &common.MainNet, &network.Liquid, nil + return &common.MainNet, &network.Liquid } func getAddress() (offchainAddr, onchainAddr string, err error) { @@ -322,10 +344,7 @@ func getAddress() (offchainAddr, onchainAddr string, err error) { return } - arkNet, liquidNet, err := getNetwork() - if err != nil { - return - } + arkNet, liquidNet := getNetwork() arkAddr, err := common.EncodeAddress(arkNet.Addr, publicKey, aspPublicKey) if err != nil { @@ -675,3 +694,41 @@ func findSweepClosure( return } + +func getRedeemBranches( + ctx *cli.Context, + explorer Explorer, + client arkv1.ArkServiceClient, + vtxos []vtxo, +) (map[string]RedeemBranch, error) { + congestionTrees := make(map[string]tree.CongestionTree, 0) // poolTxid -> congestionTree + redeemBranches := make(map[string]RedeemBranch, 0) // vtxo.txid -> redeemBranch + + for _, vtxo := range vtxos { + if _, ok := congestionTrees[vtxo.poolTxid]; !ok { + round, err := client.GetRound(ctx.Context, &arkv1.GetRoundRequest{ + Txid: vtxo.poolTxid, + }) + if err != nil { + return nil, err + } + + treeFromRound := round.GetRound().GetCongestionTree() + congestionTree, err := toCongestionTree(treeFromRound) + if err != nil { + return nil, err + } + + congestionTrees[vtxo.poolTxid] = congestionTree + } + + redeemBranch, err := newRedeemBranch(ctx, explorer, congestionTrees[vtxo.poolTxid], vtxo) + if err != nil { + return nil, err + } + + redeemBranches[vtxo.txid] = redeemBranch + } + + return redeemBranches, nil +} diff --git a/client/explorer.go b/client/explorer.go index d76cf8c..427555f 100644 --- a/client/explorer.go +++ b/client/explorer.go @@ -1,6 +1,9 @@ package main import ( + "fmt" + "io" + "net/http" "strings" "github.com/vulpemventures/go-elements/transaction" @@ -12,12 +15,17 @@ type Explorer interface { } type explorer struct { - cache map[string]string + cache map[string]string + baseUrl string } func NewExplorer() Explorer { + _, net := getNetwork() + baseUrl := explorerUrl[net.Name] + return &explorer{ - cache: make(map[string]string), + cache: make(map[string]string), + baseUrl: baseUrl, } } @@ -26,7 +34,7 @@ func (e *explorer) GetTxHex(txid string) (string, error) { return hex, nil } - txHex, err := getTxHex(txid) + txHex, err := e.getTxHex(txid) if err != nil { return "", err } @@ -55,3 +63,23 @@ func (e *explorer) Broadcast(txHex string) (string, error) { return txid, nil } + +func (e *explorer) getTxHex(txid string) (string, error) { + resp, err := http.Get(fmt.Sprintf("%s/tx/%s/hex", e.baseUrl, txid)) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf(string(body)) + } + + hex := string(body) + e.cache[txid] = hex + return hex, nil +} diff --git a/client/init.go b/client/init.go index 78f1ab1..d9b60b1 100644 --- a/client/init.go +++ b/client/init.go @@ -110,10 +110,7 @@ func initWallet(ctx *cli.Context, key, password string) error { cypher := NewAES128Cypher() - arkNetwork, _, err := getNetwork() - if err != nil { - return err - } + arkNetwork, _ := getNetwork() publicKey, err := common.EncodePubKey(arkNetwork.PubKey, privateKey.PubKey()) if err != nil { diff --git a/client/redeem.go b/client/redeem.go index 321e43d..c6315c4 100644 --- a/client/redeem.go +++ b/client/redeem.go @@ -10,7 +10,6 @@ import ( "time" arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1" - "github.com/ark-network/ark/common/tree" "github.com/urfave/cli/v2" "github.com/vulpemventures/go-elements/address" "github.com/vulpemventures/go-elements/psetv2" @@ -61,7 +60,7 @@ func redeemAction(ctx *cli.Context) error { if err != nil { return fmt.Errorf("invalid onchain address: unknown network") } - _, liquidNet, _ := getNetwork() + _, liquidNet := getNetwork() if net.Name != liquidNet.Name { return fmt.Errorf("invalid onchain address: must be for %s network", liquidNet.Name) } @@ -105,7 +104,9 @@ func collaborativeRedeem(ctx *cli.Context, addr string, amount uint64) error { } defer close() - vtxos, err := getVtxos(ctx, client, offchainAddr) + explorer := NewExplorer() + + vtxos, err := getVtxos(ctx, explorer, client, offchainAddr, true) if err != nil { return err } @@ -189,7 +190,8 @@ func unilateralRedeem(ctx *cli.Context, addr string) error { return err } - vtxos, err := getVtxos(ctx, client, offchainAddr) + explorer := NewExplorer() + vtxos, err := getVtxos(ctx, explorer, client, offchainAddr, false) if err != nil { return err } @@ -214,46 +216,25 @@ func unilateralRedeem(ctx *cli.Context, addr string) error { return err } - congestionTrees := make(map[string]tree.CongestionTree, 0) + // transactionsMap avoid duplicates transactionsMap := make(map[string]struct{}, 0) transactions := make([]string, 0) - for _, vtxo := range vtxos { - if _, ok := congestionTrees[vtxo.poolTxid]; !ok { - round, err := client.GetRound(ctx.Context, &arkv1.GetRoundRequest{ - Txid: vtxo.poolTxid, - }) - if err != nil { - return err - } + redeemBranches, err := getRedeemBranches(ctx, explorer, client, vtxos) + if err != nil { + return err + } - treeFromRound := round.GetRound().GetCongestionTree() - congestionTree, err := toCongestionTree(treeFromRound) - if err != nil { - return err - } - - congestionTrees[vtxo.poolTxid] = congestionTree + for _, branch := range redeemBranches { + if err := branch.AddVtxoInput(updater); err != nil { + return err } - redeemBranch, err := newRedeemBranch(ctx, congestionTrees[vtxo.poolTxid], vtxo) + branchTxs, err := branch.RedeemPath() if err != nil { return err } - if err := redeemBranch.UpdatePath(); err != nil { - return err - } - - branchTxs, err := redeemBranch.RedeemPath() - if err != nil { - return err - } - - if err := redeemBranch.AddVtxoInput(updater); err != nil { - return err - } - for _, txHex := range branchTxs { if _, ok := transactionsMap[txHex]; !ok { transactions = append(transactions, txHex) @@ -262,10 +243,7 @@ func unilateralRedeem(ctx *cli.Context, addr string) error { } } - _, net, err := getNetwork() - if err != nil { - return err - } + _, net := getNetwork() outputs := []psetv2.OutputArgs{ { @@ -307,8 +285,6 @@ func unilateralRedeem(ctx *cli.Context, addr string) error { return err } - explorer := NewExplorer() - for i, txHex := range transactions { for { txid, err := explorer.Broadcast(txHex) diff --git a/client/send.go b/client/send.go index 3c11f25..534c76f 100644 --- a/client/send.go +++ b/client/send.go @@ -102,7 +102,9 @@ func sendAction(ctx *cli.Context) error { } defer close() - vtxos, err := getVtxos(ctx, client, offchainAddr) + explorer := NewExplorer() + + vtxos, err := getVtxos(ctx, explorer, client, offchainAddr, true) if err != nil { return err } diff --git a/client/signer.go b/client/signer.go index bc37da1..e889c1a 100644 --- a/client/signer.go +++ b/client/signer.go @@ -81,10 +81,7 @@ func signPset( return err } - _, liquidNet, err := getNetwork() - if err != nil { - return err - } + _, liquidNet := getNetwork() prevoutsScripts := make([][]byte, 0) prevoutsValues := make([][]byte, 0) diff --git a/client/unilateral_redeem.go b/client/unilateral_redeem.go index 6d338af..13a7c1e 100644 --- a/client/unilateral_redeem.go +++ b/client/unilateral_redeem.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "time" "github.com/ark-network/ark/common/tree" "github.com/btcsuite/btcd/btcec/v2/schnorr" @@ -12,12 +13,12 @@ import ( ) type RedeemBranch interface { - // UpdatePath checks for transactions of the branch onchain and updates the branch accordingly - UpdatePath() error - // Redeem will sign the branch of the tree and return the associated signed pset + the vtxo input + // RedeemPath returns the list of transactions to broadcast in order to access the vtxo output RedeemPath() ([]string, error) // AddInput adds the vtxo input created by the branch AddVtxoInput(updater *psetv2.Updater) error + // ExpireAt returns the expiration time of the branch + ExpireAt() (*time.Time, error) } type redeemBranch struct { @@ -25,10 +26,22 @@ type redeemBranch struct { branch []*psetv2.Pset internalKey *secp256k1.PublicKey sweepClosure *taproot.TapElementsLeaf + lifetime time.Duration + explorer Explorer } -func newRedeemBranch(ctx *cli.Context, congestionTree tree.CongestionTree, vtxo vtxo) (RedeemBranch, error) { - sweepClosure, _, err := findSweepClosure(congestionTree) +func newRedeemBranch( + ctx *cli.Context, + explorer Explorer, + congestionTree tree.CongestionTree, + vtxo vtxo, +) (RedeemBranch, error) { + sweepClosure, seconds, err := findSweepClosure(congestionTree) + if err != nil { + return nil, err + } + + lifetime, err := time.ParseDuration(fmt.Sprintf("%ds", seconds)) if err != nil { return nil, err } @@ -58,43 +71,21 @@ func newRedeemBranch(ctx *cli.Context, congestionTree tree.CongestionTree, vtxo branch: branch, internalKey: internalKey, sweepClosure: sweepClosure, + lifetime: lifetime, + explorer: explorer, }, nil } -// UpdatePath checks for transactions of the branch onchain and updates the branch accordingly -func (r *redeemBranch) UpdatePath() error { - for i := len(r.branch) - 1; i >= 0; i-- { - pset := r.branch[i] - unsignedTx, err := pset.UnsignedTx() - if err != nil { - return err - } - - txHash := unsignedTx.TxHash().String() - - _, err = getTxHex(txHash) - if err != nil { - continue - } - - // if no error, the tx exists onchain, so we can remove it (+ the parents) from the branch - if i == len(r.branch)-1 { - r.branch = []*psetv2.Pset{} - } else { - r.branch = r.branch[i+1:] - } - - break - } - - return nil -} - // RedeemPath returns the list of transactions to broadcast in order to access the vtxo output func (r *redeemBranch) RedeemPath() ([]string, error) { transactions := make([]string, 0, len(r.branch)) - for _, pset := range r.branch { + offchainPath, err := r.offchainPath() + if err != nil { + return nil, err + } + + for _, pset := range offchainPath { for i, input := range pset.Inputs { if len(input.TapLeafScript) == 0 { return nil, fmt.Errorf("tap leaf script not found on input #%d", i) @@ -183,3 +174,68 @@ func (r *redeemBranch) AddVtxoInput(updater *psetv2.Updater) error { return nil } + +func (r *redeemBranch) ExpireAt() (*time.Time, error) { + lastKnownBlocktime := int64(0) + + confirmed, blocktime, _ := getTxBlocktime(r.vtxo.poolTxid) + + if confirmed { + lastKnownBlocktime = blocktime + } else { + expirationFromNow := time.Now().Add(time.Minute).Add(r.lifetime) + return &expirationFromNow, nil + } + + for _, pset := range r.branch { + utx, _ := pset.UnsignedTx() + txid := utx.TxHash().String() + + confirmed, blocktime, err := getTxBlocktime(txid) + if err != nil { + break + } + + if confirmed { + lastKnownBlocktime = blocktime + continue + } + + break + } + + t := time.Unix(lastKnownBlocktime, 0).Add(r.lifetime) + return &t, nil +} + +// offchainPath checks for transactions of the branch onchain and returns only the offchain part +func (r *redeemBranch) offchainPath() ([]*psetv2.Pset, error) { + offchainPath := append([]*psetv2.Pset{}, r.branch...) + + for i := len(r.branch) - 1; i >= 0; i-- { + pset := r.branch[i] + unsignedTx, err := pset.UnsignedTx() + if err != nil { + fmt.Println("error", err) + return nil, err + } + + txHash := unsignedTx.TxHash().String() + + _, err = r.explorer.GetTxHex(txHash) + if err != nil { + continue + } + + // if no error, the tx exists onchain, so we can remove it (+ the parents) from the branch + if i == len(r.branch)-1 { + offchainPath = []*psetv2.Pset{} + } else { + offchainPath = r.branch[i+1:] + } + + break + } + + return offchainPath, nil +} diff --git a/server/internal/infrastructure/tx-builder/covenant/builder.go b/server/internal/infrastructure/tx-builder/covenant/builder.go index 0e2c90a..9548865 100644 --- a/server/internal/infrastructure/tx-builder/covenant/builder.go +++ b/server/internal/infrastructure/tx-builder/covenant/builder.go @@ -341,6 +341,31 @@ func (b *txBuilder) createPoolTx( return nil, err } } + } else if feeAmount-dust > 0 { + newUtxos, change, err := b.wallet.SelectUtxos(ctx, b.net.AssetID, feeAmount-dust) + if err != nil { + return nil, err + } + + if change > 0 { + if change < dustLimit { + feeAmount += change + } else { + if err := updater.AddOutputs([]psetv2.OutputArgs{ + { + Asset: b.net.AssetID, + Amount: change, + Script: aspScript, + }, + }); err != nil { + return nil, err + } + } + } + + if err := addInputs(updater, newUtxos); err != nil { + return nil, err + } } // add fee output