CLI with ARK Sdk (#307)

* Export new methods

* Use sdk in CLI

* pr review refactor

* go sync

* Fixes

* run integration test on every change

* fixes

* go sync

* fix

* Persist explorer url

* Fix decoding bitcoin address

* Add missing timeout to e2e test

* Fix

* Fixes

---------

Co-authored-by: altafan <18440657+altafan@users.noreply.github.com>
This commit is contained in:
Dusan Sekulic
2024-09-13 19:13:48 +02:00
committed by GitHub
parent f9cf449104
commit 4304626d08
45 changed files with 598 additions and 6182 deletions

View File

@@ -2,14 +2,10 @@ name: ci_integration
on:
push:
paths:
- "server/**"
branches: [master]
pull_request:
branches:
- master
paths:
- "server/**"
jobs:
test:

View File

@@ -1,225 +0,0 @@
package covenant
import (
"fmt"
"math"
"sync"
"time"
arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1"
"github.com/ark-network/ark/client/flags"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common/descriptor"
"github.com/urfave/cli/v2"
)
func (*covenantLiquidCLI) Balance(ctx *cli.Context) error {
computeExpiryDetails := ctx.Bool(flags.ExpiryDetailsFlag.Name)
client, cancel, err := getClientFromState(ctx)
if err != nil {
return err
}
defer cancel()
offchainAddr, boardingAddr, redemptionAddr, err := getAddress(ctx)
if err != nil {
return err
}
// No need to check for error here becuase this function is called also by getAddress().
// nolint:all
unilateralExitDelay, _ := utils.GetUnilateralExitDelay(ctx)
boardingDescriptor, err := utils.GetBoardingDescriptor(ctx)
if err != nil {
return err
}
desc, err := descriptor.ParseTaprootDescriptor(boardingDescriptor)
if err != nil {
return err
}
_, timeoutBoarding, err := descriptor.ParseBoardingDescriptor(*desc)
if err != nil {
return err
}
wg := &sync.WaitGroup{}
wg.Add(3)
chRes := make(chan balanceRes, 3)
go func() {
defer wg.Done()
explorer := utils.NewExplorer(ctx)
balance, amountByExpiration, err := getOffchainBalance(
ctx, explorer, client, offchainAddr, computeExpiryDetails,
)
if err != nil {
chRes <- balanceRes{0, 0, nil, nil, err}
return
}
chRes <- balanceRes{balance, 0, nil, amountByExpiration, nil}
}()
go func() {
defer wg.Done()
explorer := utils.NewExplorer(ctx)
spendableBalance, lockedBalance, err := explorer.GetDelayedBalance(boardingAddr, int64(timeoutBoarding))
if err != nil {
chRes <- balanceRes{0, 0, nil, nil, err}
return
}
chRes <- balanceRes{0, spendableBalance, lockedBalance, nil, nil}
}()
go func() {
defer wg.Done()
explorer := utils.NewExplorer(ctx)
spendableBalance, lockedBalance, err := explorer.GetDelayedBalance(
redemptionAddr, unilateralExitDelay,
)
if err != nil {
chRes <- balanceRes{0, 0, nil, nil, err}
return
}
chRes <- balanceRes{0, spendableBalance, lockedBalance, nil, err}
}()
wg.Wait()
lockedOnchainBalance := []map[string]interface{}{}
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 {
return res.err
}
if res.offchainBalance > 0 {
offchainBalance = res.offchainBalance
}
if res.onchainSpendableBalance > 0 {
onchainBalance += res.onchainSpendableBalance
}
if res.offchainBalanceByExpiration != nil {
for timestamp, amount := range res.offchainBalanceByExpiration {
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,
},
)
}
}
if res.onchainLockedBalance != nil {
for timestamp, amount := range res.onchainLockedBalance {
fancyTime := time.Unix(timestamp, 0).Format("2006-01-02 15:04:05")
lockedOnchainBalance = append(
lockedOnchainBalance,
map[string]interface{}{
"spendable_at": fancyTime,
"amount": amount,
},
)
}
}
count++
if count == 3 {
break
}
}
response := make(map[string]interface{})
response["onchain_balance"] = map[string]interface{}{
"spendable_amount": onchainBalance,
}
if len(lockedOnchainBalance) > 0 {
response["onchain_balance"].(map[string]interface{})["locked_amount"] = lockedOnchainBalance
}
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
}
offchainBalanceJSON["details"] = details
response["offchain_balance"] = offchainBalanceJSON
return utils.PrintJSON(response)
}
type balanceRes struct {
offchainBalance uint64
onchainSpendableBalance uint64
onchainLockedBalance map[int64]uint64
offchainBalanceByExpiration map[int64]uint64
err error
}
func getOffchainBalance(
ctx *cli.Context, explorer utils.Explorer, client arkv1.ArkServiceClient,
addr string, computeExpiration bool,
) (uint64, map[int64]uint64, error) {
amountByExpiration := make(map[int64]uint64, 0)
vtxos, err := getVtxos(ctx, explorer, client, addr, computeExpiration)
if err != nil {
return 0, nil, err
}
var balance uint64
for _, vtxo := range vtxos {
balance += vtxo.amount
if vtxo.expireAt != nil {
expiration := vtxo.expireAt.Unix()
if _, ok := amountByExpiration[expiration]; !ok {
amountByExpiration[expiration] = 0
}
amountByExpiration[expiration] += vtxo.amount
}
}
return balance, amountByExpiration, nil
}

View File

@@ -1,148 +0,0 @@
package covenant
import (
"fmt"
"time"
arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common/descriptor"
"github.com/urfave/cli/v2"
)
func (c *covenantLiquidCLI) Claim(ctx *cli.Context) error {
client, cancel, err := getClientFromState(ctx)
if err != nil {
return err
}
defer cancel()
offchainAddr, boardingAddr, _, err := getAddress(ctx)
if err != nil {
return err
}
boardingDescriptor, err := utils.GetBoardingDescriptor(ctx)
if err != nil {
return err
}
desc, err := descriptor.ParseTaprootDescriptor(boardingDescriptor)
if err != nil {
return err
}
_, timeoutBoarding, err := descriptor.ParseBoardingDescriptor(*desc)
if err != nil {
return err
}
explorer := utils.NewExplorer(ctx)
boardingUtxosFromExplorer, err := explorer.GetUtxos(boardingAddr)
if err != nil {
return err
}
now := time.Now()
boardingUtxos := make([]utils.Utxo, 0, len(boardingUtxosFromExplorer))
for _, utxo := range boardingUtxosFromExplorer {
u := utils.NewUtxo(utxo, uint(timeoutBoarding))
if u.SpendableAt.Before(now) {
continue // cannot claim if onchain spendable
}
boardingUtxos = append(boardingUtxos, u)
}
var pendingBalance uint64
for _, utxo := range boardingUtxos {
pendingBalance += utxo.Amount
}
if pendingBalance == 0 {
return fmt.Errorf("no boarding utxos to claim")
}
receiver := receiver{
To: offchainAddr,
Amount: pendingBalance,
}
if len(ctx.String("password")) == 0 {
if ok := askForConfirmation(
fmt.Sprintf(
"claim %d satoshis from %d boarding utxos",
pendingBalance, len(boardingUtxos),
),
); !ok {
return nil
}
}
return selfTransferAllPendingPayments(
ctx, client, boardingUtxos, receiver, boardingDescriptor,
)
}
func selfTransferAllPendingPayments(
ctx *cli.Context,
client arkv1.ArkServiceClient,
boardingUtxos []utils.Utxo,
myself receiver,
desc string,
) error {
inputs := make([]*arkv1.Input, 0, len(boardingUtxos))
for _, outpoint := range boardingUtxos {
inputs = append(inputs, &arkv1.Input{
Input: &arkv1.Input_BoardingInput{
BoardingInput: &arkv1.BoardingInput{
Txid: outpoint.Txid,
Vout: outpoint.Vout,
Descriptor_: desc,
},
},
})
}
receiversOutput := []*arkv1.Output{
{
Address: myself.To,
Amount: myself.Amount,
},
}
secKey, err := utils.PrivateKeyFromPassword(ctx)
if err != nil {
return err
}
registerResponse, err := client.RegisterPayment(
ctx.Context, &arkv1.RegisterPaymentRequest{Inputs: inputs},
)
if err != nil {
return err
}
_, err = client.ClaimPayment(ctx.Context, &arkv1.ClaimPaymentRequest{
Id: registerResponse.GetId(),
Outputs: []*arkv1.Output{{Address: myself.To, Amount: myself.Amount}},
})
if err != nil {
return err
}
poolTxID, err := handleRoundStream(
ctx, client, registerResponse.GetId(), make([]vtxo, 0),
len(boardingUtxos) > 0, secKey, receiversOutput,
)
if err != nil {
return err
}
return utils.PrintJSON(map[string]interface{}{
"pool_txid": poolTxID,
})
}

View File

@@ -1,491 +0,0 @@
package covenant
import (
"fmt"
"math"
"time"
arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1"
"github.com/ark-network/ark/client/interfaces"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common"
"github.com/ark-network/ark/common/descriptor"
"github.com/ark-network/ark/common/tree"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v2"
"github.com/vulpemventures/go-elements/address"
"github.com/vulpemventures/go-elements/payment"
"github.com/vulpemventures/go-elements/psetv2"
)
type covenantLiquidCLI struct{}
func (c *covenantLiquidCLI) SendAsync(ctx *cli.Context) error {
return fmt.Errorf("not implemented")
}
func (c *covenantLiquidCLI) Receive(ctx *cli.Context) error {
offchainAddr, boardingAddr, _, err := getAddress(ctx)
if err != nil {
return err
}
return utils.PrintJSON(map[string]interface{}{
"offchain_address": offchainAddr,
"boarding_address": boardingAddr,
})
}
func (c *covenantLiquidCLI) Redeem(ctx *cli.Context) error {
addr := ctx.String("address")
amount := ctx.Uint64("amount")
force := ctx.Bool("force")
if len(addr) <= 0 && !force {
return fmt.Errorf("missing address flag (--address)")
}
if !force && amount <= 0 {
return fmt.Errorf("missing amount flag (--amount)")
}
client, clean, err := getClientFromState(ctx)
if err != nil {
return err
}
defer clean()
if force {
if amount > 0 {
fmt.Printf("WARNING: unilateral exit (--force) ignores --amount flag, it will redeem all your VTXOs\n")
}
return unilateralRedeem(ctx, client)
}
return collaborativeRedeem(ctx, client, addr, amount)
}
func New() interfaces.CLI {
return &covenantLiquidCLI{}
}
type receiver struct {
To string `json:"to"`
Amount uint64 `json:"amount"`
}
func (r *receiver) isOnchain() bool {
_, err := address.ToOutputScript(r.To)
return err == nil
}
func sendOnchain(ctx *cli.Context, receivers []receiver) (string, error) {
pset, err := psetv2.New(nil, nil, nil)
if err != nil {
return "", err
}
updater, err := psetv2.NewUpdater(pset)
if err != nil {
return "", err
}
net, err := utils.GetNetwork(ctx)
if err != nil {
return "", err
}
dust, err := utils.GetDust(ctx)
if err != nil {
return "", err
}
liquidNet := toElementsNetwork(net)
targetAmount := uint64(0)
for _, receiver := range receivers {
targetAmount += receiver.Amount
if receiver.Amount < dust {
return "", fmt.Errorf("invalid amount (%d), must be greater than dust %d", receiver.Amount, dust)
}
script, err := address.ToOutputScript(receiver.To)
if err != nil {
return "", err
}
if err := updater.AddOutputs([]psetv2.OutputArgs{
{
Asset: liquidNet.AssetID,
Amount: receiver.Amount,
Script: script,
},
}); err != nil {
return "", err
}
}
explorer := utils.NewExplorer(ctx)
utxos, change, err := coinSelectOnchain(
ctx, explorer, targetAmount, nil,
)
if err != nil {
return "", err
}
if err := addInputs(ctx, updater, utxos); err != nil {
return "", err
}
if change > 0 {
_, changeAddr, _, err := getAddress(ctx)
if err != nil {
return "", err
}
changeScript, err := address.ToOutputScript(changeAddr)
if err != nil {
return "", err
}
if err := updater.AddOutputs([]psetv2.OutputArgs{
{
Asset: liquidNet.AssetID,
Amount: change,
Script: changeScript,
},
}); err != nil {
return "", err
}
}
utx, err := pset.UnsignedTx()
if err != nil {
return "", err
}
vBytes := utx.VirtualSize()
feeAmount := uint64(math.Ceil(float64(vBytes) * 0.5))
if change > feeAmount {
updater.Pset.Outputs[len(updater.Pset.Outputs)-1].Value = change - feeAmount
} else if change == feeAmount {
updater.Pset.Outputs = updater.Pset.Outputs[:len(updater.Pset.Outputs)-1]
} else { // change < feeAmount
if change > 0 {
updater.Pset.Outputs = updater.Pset.Outputs[:len(updater.Pset.Outputs)-1]
}
// reselect the difference
selected, newChange, err := coinSelectOnchain(
ctx, explorer, feeAmount-change, utxos,
)
if err != nil {
return "", err
}
if err := addInputs(ctx, updater, selected); err != nil {
return "", err
}
if newChange > 0 {
_, changeAddr, _, err := getAddress(ctx)
if err != nil {
return "", err
}
changeScript, err := address.ToOutputScript(changeAddr)
if err != nil {
return "", err
}
if err := updater.AddOutputs([]psetv2.OutputArgs{
{
Asset: liquidNet.AssetID,
Amount: newChange,
Script: changeScript,
},
}); err != nil {
return "", err
}
}
}
if err := updater.AddOutputs([]psetv2.OutputArgs{
{
Asset: liquidNet.AssetID,
Amount: feeAmount,
},
}); err != nil {
return "", err
}
prvKey, err := utils.PrivateKeyFromPassword(ctx)
if err != nil {
return "", err
}
if err := signPset(ctx, updater.Pset, explorer, prvKey); err != nil {
return "", err
}
if err := psetv2.FinalizeAll(updater.Pset); err != nil {
return "", err
}
return updater.Pset.ToBase64()
}
func coinSelectOnchain(
ctx *cli.Context,
explorer utils.Explorer, targetAmount uint64, exclude []utils.Utxo,
) ([]utils.Utxo, uint64, error) {
_, boardingAddr, redemptionAddr, err := getAddress(ctx)
if err != nil {
return nil, 0, err
}
boardingUtxosFromExplorer, err := explorer.GetUtxos(boardingAddr)
if err != nil {
return nil, 0, err
}
utxos := make([]utils.Utxo, 0)
selectedAmount := uint64(0)
now := time.Now()
boardingDescriptor, err := utils.GetBoardingDescriptor(ctx)
if err != nil {
return nil, 0, err
}
desc, err := descriptor.ParseTaprootDescriptor(boardingDescriptor)
if err != nil {
return nil, 0, err
}
_, timeoutBoarding, err := descriptor.ParseBoardingDescriptor(*desc)
if err != nil {
return nil, 0, err
}
for _, utxo := range boardingUtxosFromExplorer {
if selectedAmount >= targetAmount {
break
}
for _, excluded := range exclude {
if utxo.Txid == excluded.Txid && utxo.Vout == excluded.Vout {
continue
}
}
utxo := utils.NewUtxo(utxo, uint(timeoutBoarding))
if utxo.SpendableAt.Before(now) {
utxos = append(utxos, utxo)
selectedAmount += utxo.Amount
}
}
if selectedAmount >= targetAmount {
return utxos, selectedAmount - targetAmount, nil
}
redemptionUtxosFromExplorer, err := explorer.GetUtxos(redemptionAddr)
if err != nil {
return nil, 0, err
}
vtxoExitDelay, err := utils.GetUnilateralExitDelay(ctx)
if err != nil {
return nil, 0, err
}
for _, utxo := range redemptionUtxosFromExplorer {
if selectedAmount >= targetAmount {
break
}
for _, excluded := range exclude {
if utxo.Txid == excluded.Txid && utxo.Vout == excluded.Vout {
continue
}
}
utxo := utils.NewUtxo(utxo, uint(vtxoExitDelay))
if utxo.SpendableAt.Before(now) {
utxos = append(utxos, utxo)
selectedAmount += utxo.Amount
}
}
if selectedAmount < targetAmount {
return nil, 0, fmt.Errorf(
"not enough funds to cover amount %d", targetAmount,
)
}
return utxos, selectedAmount - targetAmount, nil
}
func addInputs(
ctx *cli.Context,
updater *psetv2.Updater,
utxos []utils.Utxo,
) error {
userPubkey, err := utils.GetWalletPublicKey(ctx)
if err != nil {
return err
}
aspPubkey, err := utils.GetAspPublicKey(ctx)
if err != nil {
return err
}
for _, utxo := range utxos {
sequence, err := utxo.Sequence()
if err != nil {
return err
}
if err := updater.AddInputs([]psetv2.InputArgs{
{
Txid: utxo.Txid,
TxIndex: utxo.Vout,
Sequence: sequence,
},
}); err != nil {
return err
}
_, leafProof, err := computeVtxoTaprootScript(
userPubkey, aspPubkey, utxo.Delay,
)
if err != nil {
return err
}
inputIndex := len(updater.Pset.Inputs) - 1
if err := updater.AddInTapLeafScript(inputIndex, psetv2.NewTapLeafScript(*leafProof, tree.UnspendableKey())); err != nil {
return err
}
}
return nil
}
func isOnchainOnly(receivers []*arkv1.Output) bool {
for _, receiver := range receivers {
isOnChain, _, _, err := decodeReceiverAddress(receiver.Address)
if err != nil {
continue
}
if !isOnChain {
return false
}
}
return true
}
func decodeReceiverAddress(addr string) (
bool, []byte, *secp256k1.PublicKey, error,
) {
outputScript, err := address.ToOutputScript(addr)
if err != nil {
_, userPubkey, _, err := common.DecodeAddress(addr)
if err != nil {
return false, nil, nil, err
}
return false, nil, userPubkey, nil
}
return true, outputScript, nil, nil
}
func getAddress(ctx *cli.Context) (offchainAddr, boardingAddr, redemptionAddr string, err error) {
userPubkey, err := utils.GetWalletPublicKey(ctx)
if err != nil {
return
}
aspPubkey, err := utils.GetAspPublicKey(ctx)
if err != nil {
return
}
unilateralExitDelay, err := utils.GetUnilateralExitDelay(ctx)
if err != nil {
return
}
boardingDescriptor, err := utils.GetBoardingDescriptor(ctx)
if err != nil {
return
}
desc, err := descriptor.ParseTaprootDescriptor(boardingDescriptor)
if err != nil {
return
}
_, timeoutBoarding, err := descriptor.ParseBoardingDescriptor(*desc)
if err != nil {
return
}
arkNet, err := utils.GetNetwork(ctx)
if err != nil {
return
}
arkAddr, err := common.EncodeAddress(arkNet.Addr, userPubkey, aspPubkey)
if err != nil {
return
}
liquidNet := toElementsNetwork(arkNet)
vtxoTapKey, _, err := computeVtxoTaprootScript(
userPubkey, aspPubkey, uint(unilateralExitDelay),
)
if err != nil {
return
}
redemptionPay, err := payment.FromTweakedKey(vtxoTapKey, &liquidNet, nil)
if err != nil {
return
}
redemptionAddr, err = redemptionPay.TaprootAddress()
if err != nil {
return
}
boardingTapKey, _, err := computeVtxoTaprootScript(
userPubkey, aspPubkey, uint(timeoutBoarding),
)
if err != nil {
return
}
boardingPay, err := payment.FromTweakedKey(boardingTapKey, &liquidNet, nil)
if err != nil {
return
}
boardingAddr, err = boardingPay.TaprootAddress()
if err != nil {
return
}
offchainAddr = arkAddr
return
}

View File

@@ -1,495 +0,0 @@
package covenant
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"time"
arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common"
"github.com/ark-network/ark/common/tree"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v2"
"github.com/vulpemventures/go-elements/psetv2"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
type vtxo struct {
amount uint64
txid string
vout uint32
poolTxid string
expireAt *time.Time
}
func getVtxos(
ctx *cli.Context, explorer utils.Explorer, client arkv1.ArkServiceClient,
addr string, computeExpiration bool,
) ([]vtxo, error) {
response, err := client.ListVtxos(ctx.Context, &arkv1.ListVtxosRequest{
Address: addr,
})
if err != nil {
return nil, err
}
vtxos := make([]vtxo, 0, len(response.GetSpendableVtxos()))
for _, v := range response.GetSpendableVtxos() {
var expireAt *time.Time
if v.ExpireAt > 0 {
t := time.Unix(v.ExpireAt, 0)
expireAt = &t
}
if v.Swept {
continue
}
if v.Outpoint.GetVtxoInput() != nil {
vtxos = append(vtxos, vtxo{
amount: v.Receiver.Amount,
txid: v.Outpoint.GetVtxoInput().GetTxid(),
vout: v.Outpoint.GetVtxoInput().GetVout(),
poolTxid: v.PoolTxid,
expireAt: expireAt,
})
}
}
if !computeExpiration {
return vtxos, nil
}
redeemBranches, err := getRedeemBranches(ctx.Context, explorer, client, vtxos)
if err != nil {
return nil, err
}
for vtxoTxid, branch := range redeemBranches {
expiration, err := branch.expireAt(ctx)
if err != nil {
return nil, err
}
for i, vtxo := range vtxos {
if vtxo.txid == vtxoTxid {
vtxos[i].expireAt = expiration
break
}
}
}
return vtxos, nil
}
func getClientFromState(ctx *cli.Context) (arkv1.ArkServiceClient, func(), error) {
state, err := utils.GetState(ctx)
if err != nil {
return nil, nil, err
}
addr := state[utils.ASP_URL]
if len(addr) <= 0 {
return nil, nil, fmt.Errorf("missing asp url")
}
return getClient(addr)
}
func getClient(addr string) (arkv1.ArkServiceClient, func(), error) {
creds := insecure.NewCredentials()
port := 80
addr = strings.TrimPrefix(addr, "http://")
if strings.HasPrefix(addr, "https://") {
addr = strings.TrimPrefix(addr, "https://")
creds = credentials.NewTLS(nil)
port = 443
}
if !strings.Contains(addr, ":") {
addr = fmt.Sprintf("%s:%d", addr, port)
}
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(creds))
if err != nil {
return nil, nil, err
}
client := arkv1.NewArkServiceClient(conn)
closeFn := func() {
err := conn.Close()
if err != nil {
fmt.Printf("error closing connection: %s\n", err)
}
}
return client, closeFn, nil
}
func getRedeemBranches(
ctx context.Context, explorer utils.Explorer, client arkv1.ArkServiceClient,
vtxos []vtxo,
) (map[string]*redeemBranch, error) {
congestionTrees := make(map[string]tree.CongestionTree, 0)
redeemBranches := make(map[string]*redeemBranch, 0)
for _, vtxo := range vtxos {
if _, ok := congestionTrees[vtxo.poolTxid]; !ok {
round, err := client.GetRound(ctx, &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(
explorer, congestionTrees[vtxo.poolTxid], vtxo,
)
if err != nil {
return nil, err
}
redeemBranches[vtxo.txid] = redeemBranch
}
return redeemBranches, nil
}
func toCongestionTree(treeFromProto *arkv1.Tree) (tree.CongestionTree, error) {
levels := make(tree.CongestionTree, 0, len(treeFromProto.Levels))
for _, level := range treeFromProto.Levels {
nodes := make([]tree.Node, 0, len(level.Nodes))
for _, node := range level.Nodes {
nodes = append(nodes, tree.Node{
Txid: node.Txid,
Tx: node.Tx,
ParentTxid: node.ParentTxid,
Leaf: false,
})
}
levels = append(levels, nodes)
}
for j, treeLvl := range levels {
for i, node := range treeLvl {
if len(levels.Children(node.Txid)) == 0 {
levels[j][i].Leaf = true
}
}
}
return levels, nil
}
func handleRoundStream(
ctx *cli.Context, client arkv1.ArkServiceClient, paymentID string,
vtxosToSign []vtxo, mustSignRoundTx bool,
secKey *secp256k1.PrivateKey, receivers []*arkv1.Output,
) (poolTxID string, err error) {
stream, err := client.GetEventStream(ctx.Context, &arkv1.GetEventStreamRequest{})
if err != nil {
return "", err
}
var pingStop func()
pingReq := &arkv1.PingRequest{
PaymentId: paymentID,
}
for pingStop == nil {
pingStop = ping(ctx.Context, client, pingReq)
}
defer pingStop()
for {
event, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
if e := event.GetRoundFailed(); e != nil {
pingStop()
return "", fmt.Errorf("round failed: %s", e.GetReason())
}
if e := event.GetRoundFinalization(); e != nil {
// stop pinging as soon as we receive some forfeit txs
pingStop()
fmt.Println("round finalization started")
roundTx := e.GetPoolTx()
ptx, err := psetv2.NewPsetFromBase64(roundTx)
if err != nil {
return "", err
}
congestionTree, err := toCongestionTree(e.GetCongestionTree())
if err != nil {
return "", err
}
connectors := e.GetConnectors()
aspPubkey, err := utils.GetAspPublicKey(ctx)
if err != nil {
return "", err
}
roundLifetime, err := utils.GetRoundLifetime(ctx)
if err != nil {
return "", err
}
if !isOnchainOnly(receivers) {
// validate the congestion tree
if err := tree.ValidateCongestionTree(
congestionTree, roundTx, aspPubkey, int64(roundLifetime),
); err != nil {
return "", err
}
}
if err := common.ValidateConnectors(roundTx, connectors); err != nil {
return "", err
}
unilateralExitDelay, err := utils.GetUnilateralExitDelay(ctx)
if err != nil {
return "", err
}
for _, receiver := range receivers {
isOnChain, onchainScript, userPubkey, err := decodeReceiverAddress(
receiver.Address,
)
if err != nil {
return "", err
}
if isOnChain {
// collaborative exit case
// search for the output in the pool tx
found := false
for _, output := range ptx.Outputs {
if bytes.Equal(output.Script, onchainScript) {
if output.Value != receiver.Amount {
return "", fmt.Errorf(
"invalid collaborative exit output amount: got %d, want %d",
output.Value, receiver.Amount,
)
}
found = true
break
}
}
if !found {
return "", fmt.Errorf(
"collaborative exit output not found: %s", receiver.Address,
)
}
continue
}
// off-chain send case
// search for the output in congestion tree
found := false
// compute the receiver output taproot key
outputTapKey, _, err := computeVtxoTaprootScript(
userPubkey, aspPubkey, uint(unilateralExitDelay),
)
if err != nil {
return "", err
}
leaves := congestionTree.Leaves()
for _, leaf := range leaves {
tx, err := psetv2.NewPsetFromBase64(leaf.Tx)
if err != nil {
return "", err
}
for _, output := range tx.Outputs {
if len(output.Script) == 0 {
continue
}
if bytes.Equal(
output.Script[2:], schnorr.SerializePubKey(outputTapKey),
) {
if output.Value != receiver.Amount {
continue
}
found = true
break
}
}
if found {
break
}
}
if !found {
return "", fmt.Errorf(
"off-chain send output not found: %s", receiver.Address,
)
}
}
fmt.Println("congestion tree validated")
explorer := utils.NewExplorer(ctx)
finalizePaymentRequest := &arkv1.FinalizePaymentRequest{}
if len(vtxosToSign) > 0 {
forfeits := e.GetForfeitTxs()
signedForfeits := make([]string, 0)
fmt.Print("signing forfeit txs... ")
connectorsTxids := make([]string, 0, len(connectors))
for _, connector := range connectors {
p, _ := psetv2.NewPsetFromBase64(connector)
utx, _ := p.UnsignedTx()
txid := utx.TxHash().String()
connectorsTxids = append(connectorsTxids, txid)
}
for _, forfeit := range forfeits {
pset, err := psetv2.NewPsetFromBase64(forfeit)
if err != nil {
return "", err
}
for _, input := range pset.Inputs {
inputTxid := chainhash.Hash(input.PreviousTxid).String()
for _, coin := range vtxosToSign {
// check if it contains one of the input to sign
if inputTxid == coin.txid {
// verify that the connector is in the connectors list
connectorTxid := chainhash.Hash(pset.Inputs[0].PreviousTxid).String()
connectorFound := false
for _, txid := range connectorsTxids {
if txid == connectorTxid {
connectorFound = true
break
}
}
if !connectorFound {
return "", fmt.Errorf("connector txid %s not found in the connectors list", connectorTxid)
}
if err := signPset(ctx, pset, explorer, secKey); err != nil {
return "", err
}
signedPset, err := pset.ToBase64()
if err != nil {
return "", err
}
signedForfeits = append(signedForfeits, signedPset)
}
}
}
}
// if no forfeit txs have been signed, start pinging again and wait for the next round
if len(signedForfeits) == 0 {
fmt.Printf("\nno forfeit txs to sign, waiting for the next round...\n")
pingStop = nil
for pingStop == nil {
pingStop = ping(ctx.Context, client, pingReq)
}
continue
}
fmt.Printf("%d signed\n", len(signedForfeits))
finalizePaymentRequest.SignedForfeitTxs = signedForfeits
}
if mustSignRoundTx {
ptx, err := psetv2.NewPsetFromBase64(roundTx)
if err != nil {
return "", err
}
if err := signPset(ctx, ptx, explorer, secKey); err != nil {
return "", err
}
signedRoundTx, err := ptx.ToBase64()
if err != nil {
return "", err
}
fmt.Println("round tx signed")
finalizePaymentRequest.SignedRoundTx = &signedRoundTx
}
fmt.Print("finalizing payment... ")
_, err = client.FinalizePayment(ctx.Context, finalizePaymentRequest)
if err != nil {
return "", err
}
fmt.Print("done.\n")
fmt.Println("waiting for round finalization...")
continue
}
if event.GetRoundFinalized() != nil {
return event.GetRoundFinalized().GetPoolTxid(), nil
}
}
return "", fmt.Errorf("stream closed unexpectedly")
}
// send 1 ping message every 5 seconds to signal to the ark service that we are still alive
// returns a function that can be used to stop the pinging
func ping(
ctx context.Context, client arkv1.ArkServiceClient, req *arkv1.PingRequest,
) func() {
_, err := client.Ping(ctx, req)
if err != nil {
return nil
}
ticker := time.NewTicker(5 * time.Second)
go func(t *time.Ticker) {
for range t.C {
// nolint
client.Ping(ctx, req)
}
}(ticker)
return ticker.Stop
}

View File

@@ -1,151 +0,0 @@
package covenant
import (
"encoding/hex"
"fmt"
"io"
"net/http"
"strconv"
"strings"
arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v2"
"github.com/vulpemventures/go-elements/network"
)
var explorerUrls = map[string]string{
common.Liquid.Name: "https://blockstream.info/liquid/api",
common.LiquidTestNet.Name: "https://blockstream.info/liquidtestnet/api",
common.LiquidRegTest.Name: "http://localhost:3001",
}
func (c *covenantLiquidCLI) Init(ctx *cli.Context) error {
key := ctx.String("prvkey")
net := strings.ToLower(ctx.String("network"))
url := ctx.String("asp-url")
explorer := ctx.String("explorer")
var explorerURL string
if len(url) <= 0 {
return fmt.Errorf("invalid asp-url")
}
if net != common.Liquid.Name && net != common.LiquidTestNet.Name && net != common.LiquidRegTest.Name {
return fmt.Errorf("invalid network")
}
if len(explorer) > 0 {
explorerURL = explorer
if err := testEsploraEndpoint(toElementsNetworkFromName(net), explorerURL); err != nil {
return fmt.Errorf("failed to connect with explorer: %s", err)
}
} else {
explorerURL = explorerUrls[net]
}
if err := connectToAsp(ctx, net, url, explorerURL); err != nil {
return err
}
password, err := utils.ReadPassword(ctx, false)
if err != nil {
return err
}
return initWallet(ctx, key, password)
}
func generateRandomPrivateKey() (*secp256k1.PrivateKey, error) {
privKey, err := btcec.NewPrivateKey()
if err != nil {
return nil, err
}
return privKey, nil
}
func testEsploraEndpoint(net network.Network, url string) error {
endpoint := fmt.Sprintf("%s/asset/%s", url, net.AssetID)
resp, err := http.Get(endpoint)
if err != nil {
return fmt.Errorf("failed to connect with explorer: (%s) %s", endpoint, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf(endpoint + " " + string(body))
}
return nil
}
func connectToAsp(ctx *cli.Context, net, url, explorer string) error {
client, close, err := getClient(url)
if err != nil {
return err
}
defer close()
resp, err := client.GetInfo(ctx.Context, &arkv1.GetInfoRequest{})
if err != nil {
return err
}
return utils.SetState(ctx, map[string]string{
utils.ASP_URL: url,
utils.NETWORK: net,
utils.ASP_PUBKEY: resp.Pubkey,
utils.ROUND_LIFETIME: strconv.Itoa(int(resp.GetRoundLifetime())),
utils.UNILATERAL_EXIT_DELAY: strconv.Itoa(int(resp.GetUnilateralExitDelay())),
utils.EXPLORER: explorer,
utils.DUST: strconv.Itoa(int(resp.GetDust())),
utils.BOARDING_TEMPLATE: resp.GetBoardingDescriptorTemplate(),
})
}
func initWallet(ctx *cli.Context, key string, password []byte) error {
var privateKey *secp256k1.PrivateKey
if len(key) <= 0 {
privKey, err := generateRandomPrivateKey()
if err != nil {
return err
}
privateKey = privKey
} else {
privKeyBytes, err := hex.DecodeString(key)
if err != nil {
return err
}
privateKey = secp256k1.PrivKeyFromBytes(privKeyBytes)
}
cypher := utils.NewAES128Cypher()
buf := privateKey.Serialize()
encryptedPrivateKey, err := cypher.Encrypt(buf, password)
if err != nil {
return err
}
passwordHash := utils.HashPassword([]byte(password))
pubkey := privateKey.PubKey().SerializeCompressed()
state := map[string]string{
utils.ENCRYPTED_PRVKEY: hex.EncodeToString(encryptedPrivateKey),
utils.PASSWORD_HASH: hex.EncodeToString(passwordHash),
utils.PUBKEY: hex.EncodeToString(pubkey),
}
if err := utils.SetState(ctx, state); err != nil {
return err
}
fmt.Println("wallet initialized")
return nil
}

View File

@@ -1,26 +0,0 @@
package covenant
import (
"fmt"
"github.com/ark-network/ark/common"
"github.com/vulpemventures/go-elements/network"
)
func toElementsNetworkFromName(name string) network.Network {
switch name {
case common.Liquid.Name:
return network.Liquid
case common.LiquidTestNet.Name:
return network.Testnet
case common.LiquidRegTest.Name:
return network.Regtest
default:
fmt.Printf("unknown network")
return network.Liquid
}
}
func toElementsNetwork(net *common.Network) network.Network {
return toElementsNetworkFromName(net.Name)
}

View File

@@ -1,223 +0,0 @@
package covenant
import (
"bufio"
"fmt"
"log"
"os"
"strings"
"time"
arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1"
"github.com/ark-network/ark/client/utils"
"github.com/urfave/cli/v2"
"github.com/vulpemventures/go-elements/address"
)
func collaborativeRedeem(
ctx *cli.Context, client arkv1.ArkServiceClient, addr string, amount uint64,
) error {
withExpiryCoinselect := ctx.Bool("enable-expiry-coinselect")
if _, err := address.ToOutputScript(addr); err != nil {
return fmt.Errorf("invalid onchain address")
}
net, err := address.NetworkForAddress(addr)
if err != nil {
return fmt.Errorf("invalid onchain address: unknown network")
}
netinstate, err := utils.GetNetwork(ctx)
if err != nil {
return err
}
dust, err := utils.GetDust(ctx)
if err != nil {
return err
}
liquidNet := toElementsNetwork(netinstate)
if net.Name != liquidNet.Name {
return fmt.Errorf("invalid onchain address: must be for %s network", liquidNet.Name)
}
if isConf, _ := address.IsConfidential(addr); isConf {
info, _ := address.FromConfidential(addr)
addr = info.Address
}
offchainAddr, _, _, err := getAddress(ctx)
if err != nil {
return err
}
receivers := []*arkv1.Output{
{
Address: addr,
Amount: amount,
},
}
explorer := utils.NewExplorer(ctx)
vtxos, err := getVtxos(ctx, explorer, client, offchainAddr, withExpiryCoinselect)
if err != nil {
return err
}
selectedCoins, changeAmount, err := coinSelect(vtxos, amount, withExpiryCoinselect, dust)
if err != nil {
return err
}
if changeAmount > 0 {
receivers = append(receivers, &arkv1.Output{
Address: offchainAddr,
Amount: changeAmount,
})
}
inputs := make([]*arkv1.Input, 0, len(selectedCoins))
for _, coin := range selectedCoins {
inputs = append(inputs, &arkv1.Input{
Input: &arkv1.Input_VtxoInput{
VtxoInput: &arkv1.VtxoInput{
Txid: coin.txid,
Vout: coin.vout,
},
},
})
}
secKey, err := utils.PrivateKeyFromPassword(ctx)
if err != nil {
return err
}
registerResponse, err := client.RegisterPayment(ctx.Context, &arkv1.RegisterPaymentRequest{
Inputs: inputs,
})
if err != nil {
return err
}
_, err = client.ClaimPayment(ctx.Context, &arkv1.ClaimPaymentRequest{
Id: registerResponse.GetId(),
Outputs: receivers,
})
if err != nil {
return err
}
poolTxID, err := handleRoundStream(
ctx,
client,
registerResponse.GetId(),
selectedCoins,
false,
secKey,
receivers,
)
if err != nil {
return err
}
return utils.PrintJSON(map[string]interface{}{
"pool_txid": poolTxID,
})
}
func unilateralRedeem(ctx *cli.Context, client arkv1.ArkServiceClient) error {
offchainAddr, _, _, err := getAddress(ctx)
if err != nil {
return err
}
explorer := utils.NewExplorer(ctx)
vtxos, err := getVtxos(ctx, explorer, client, offchainAddr, false)
if err != nil {
return err
}
totalVtxosAmount := uint64(0)
for _, vtxo := range vtxos {
totalVtxosAmount += vtxo.amount
}
if len(ctx.String("password")) == 0 {
ok := askForConfirmation(fmt.Sprintf("redeem %d sats ?", totalVtxosAmount))
if !ok {
return fmt.Errorf("aborting unilateral exit")
}
}
// transactionsMap avoid duplicates
transactionsMap := make(map[string]struct{}, 0)
transactions := make([]string, 0)
redeemBranches, err := getRedeemBranches(ctx.Context, explorer, client, vtxos)
if err != nil {
return err
}
for _, branch := range redeemBranches {
branchTxs, err := branch.redeemPath()
if err != nil {
return err
}
for _, txHex := range branchTxs {
if _, ok := transactionsMap[txHex]; !ok {
transactions = append(transactions, txHex)
transactionsMap[txHex] = struct{}{}
}
}
}
for i, txHex := range transactions {
for {
txid, err := explorer.Broadcast(txHex)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "bad-txns-inputs-missingorspent") {
time.Sleep(1 * time.Second)
} else {
return err
}
}
if len(txid) > 0 {
fmt.Printf("(%d/%d) broadcasted tx %s\n", i+1, len(transactions), txid)
break
}
}
}
return nil
}
// askForConfirmation asks the user for confirmation. A user must type in "yes" or "no" and then press enter.
// if the input is not recognized, it will ask again.
func askForConfirmation(s string) bool {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Printf("%s [y/n]: ", s)
response, err := reader.ReadString('\n')
if err != nil {
log.Fatal(err)
}
response = strings.ToLower(strings.TrimSpace(response))
if response == "y" || response == "yes" {
return true
} else if response == "n" || response == "no" {
return false
}
}
}

View File

@@ -1,218 +0,0 @@
package covenant
import (
"fmt"
"time"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common/tree"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v2"
"github.com/vulpemventures/go-elements/psetv2"
"github.com/vulpemventures/go-elements/taproot"
)
type redeemBranch struct {
vtxo *vtxo
branch []*psetv2.Pset
internalKey *secp256k1.PublicKey
sweepClosure *taproot.TapElementsLeaf
lifetime time.Duration
explorer utils.Explorer
}
func newRedeemBranch(
explorer utils.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
}
nodes, err := congestionTree.Branch(vtxo.txid)
if err != nil {
return nil, err
}
branch := make([]*psetv2.Pset, 0, len(nodes))
for _, node := range nodes {
pset, err := psetv2.NewPsetFromBase64(node.Tx)
if err != nil {
return nil, err
}
branch = append(branch, pset)
}
xOnlyKey := branch[0].Inputs[0].TapInternalKey
internalKey, err := schnorr.ParsePubKey(xOnlyKey)
if err != nil {
return nil, err
}
return &redeemBranch{
vtxo: &vtxo,
branch: branch,
internalKey: internalKey,
sweepClosure: sweepClosure,
lifetime: lifetime,
explorer: explorer,
}, 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))
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)
}
for _, leaf := range input.TapLeafScript {
closure, err := tree.DecodeClosure(leaf.Script)
if err != nil {
return nil, err
}
switch closure.(type) {
case *tree.UnrollClosure:
controlBlock, err := leaf.ControlBlock.ToBytes()
if err != nil {
return nil, err
}
unsignedTx, err := pset.UnsignedTx()
if err != nil {
return nil, err
}
unsignedTx.Inputs[i].Witness = [][]byte{
leaf.Script,
controlBlock[:],
}
hex, err := unsignedTx.ToHex()
if err != nil {
return nil, err
}
transactions = append(transactions, hex)
}
}
}
}
return transactions, nil
}
func (r *redeemBranch) expireAt(*cli.Context) (*time.Time, error) {
lastKnownBlocktime := int64(0)
confirmed, blocktime, _ := r.explorer.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 := r.explorer.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 {
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
}
func findSweepClosure(
congestionTree tree.CongestionTree,
) (*taproot.TapElementsLeaf, uint, error) {
root, err := congestionTree.Root()
if err != nil {
return nil, 0, err
}
// find the sweep closure
tx, err := psetv2.NewPsetFromBase64(root.Tx)
if err != nil {
return nil, 0, err
}
var seconds uint
var sweepClosure *taproot.TapElementsLeaf
for _, tapLeaf := range tx.Inputs[0].TapLeafScript {
closure := &tree.CSVSigClosure{}
valid, err := closure.Decode(tapLeaf.Script)
if err != nil {
continue
}
if valid && closure.Seconds > seconds {
seconds = closure.Seconds
sweepClosure = &tapLeaf.TapElementsLeaf
}
}
if sweepClosure == nil {
return nil, 0, fmt.Errorf("sweep closure not found")
}
return sweepClosure, seconds, nil
}

View File

@@ -1,233 +0,0 @@
package covenant
import (
"bytes"
"encoding/json"
"fmt"
"sort"
arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common"
"github.com/urfave/cli/v2"
)
func (c *covenantLiquidCLI) Send(ctx *cli.Context) error {
if !ctx.IsSet("receivers") && !ctx.IsSet("to") && !ctx.IsSet("amount") {
return fmt.Errorf("missing destination, either use --to and --amount to send or --receivers to send to many")
}
receivers := ctx.String("receivers")
to := ctx.String("to")
amount := ctx.Uint64("amount")
var receiversJSON []receiver
if len(receivers) > 0 {
if err := json.Unmarshal([]byte(receivers), &receiversJSON); err != nil {
return fmt.Errorf("invalid receivers: %s", err)
}
} else {
receiversJSON = []receiver{
{
To: to,
Amount: amount,
},
}
}
if len(receiversJSON) <= 0 {
return fmt.Errorf("no receivers specified")
}
onchainReceivers := make([]receiver, 0)
offchainReceivers := make([]receiver, 0)
for _, receiver := range receiversJSON {
if receiver.isOnchain() {
onchainReceivers = append(onchainReceivers, receiver)
} else {
offchainReceivers = append(offchainReceivers, receiver)
}
}
explorer := utils.NewExplorer(ctx)
if len(onchainReceivers) > 0 {
pset, err := sendOnchain(ctx, onchainReceivers)
if err != nil {
return err
}
txid, err := explorer.Broadcast(pset)
if err != nil {
return err
}
return utils.PrintJSON(map[string]interface{}{
"txid": txid,
})
}
if len(offchainReceivers) > 0 {
if err := sendOffchain(ctx, offchainReceivers); err != nil {
return err
}
}
return nil
}
func sendOffchain(ctx *cli.Context, receivers []receiver) error {
withExpiryCoinselect := ctx.Bool("enable-expiry-coinselect")
offchainAddr, _, _, err := getAddress(ctx)
if err != nil {
return err
}
_, _, aspPubKey, err := common.DecodeAddress(offchainAddr)
if err != nil {
return err
}
dust, err := utils.GetDust(ctx)
if err != nil {
return err
}
receiversOutput := make([]*arkv1.Output, 0)
sumOfReceivers := uint64(0)
for _, receiver := range receivers {
_, _, aspKey, err := common.DecodeAddress(receiver.To)
if err != nil {
return fmt.Errorf("invalid receiver address: %s", err)
}
if !bytes.Equal(
aspPubKey.SerializeCompressed(), aspKey.SerializeCompressed(),
) {
return fmt.Errorf("invalid receiver address '%s': must be associated with the connected service provider", receiver.To)
}
if receiver.Amount < dust {
return fmt.Errorf("invalid amount (%d), must be greater than dust %d", receiver.Amount, dust)
}
receiversOutput = append(receiversOutput, &arkv1.Output{
Address: receiver.To,
Amount: uint64(receiver.Amount),
})
sumOfReceivers += receiver.Amount
}
client, close, err := getClientFromState(ctx)
if err != nil {
return err
}
defer close()
explorer := utils.NewExplorer(ctx)
vtxos, err := getVtxos(ctx, explorer, client, offchainAddr, withExpiryCoinselect)
if err != nil {
return err
}
selectedCoins, changeAmount, err := coinSelect(vtxos, sumOfReceivers, withExpiryCoinselect, dust)
if err != nil {
return err
}
if changeAmount > 0 {
changeReceiver := &arkv1.Output{
Address: offchainAddr,
Amount: changeAmount,
}
receiversOutput = append(receiversOutput, changeReceiver)
}
inputs := make([]*arkv1.Input, 0, len(selectedCoins))
for _, coin := range selectedCoins {
inputs = append(inputs, &arkv1.Input{
Input: &arkv1.Input_VtxoInput{
VtxoInput: &arkv1.VtxoInput{
Txid: coin.txid,
Vout: coin.vout,
},
},
})
}
secKey, err := utils.PrivateKeyFromPassword(ctx)
if err != nil {
return err
}
registerResponse, err := client.RegisterPayment(
ctx.Context, &arkv1.RegisterPaymentRequest{Inputs: inputs},
)
if err != nil {
return err
}
_, err = client.ClaimPayment(ctx.Context, &arkv1.ClaimPaymentRequest{
Id: registerResponse.GetId(),
Outputs: receiversOutput,
})
if err != nil {
return err
}
poolTxID, err := handleRoundStream(
ctx, client, registerResponse.GetId(),
selectedCoins, false, secKey, receiversOutput,
)
if err != nil {
return err
}
return utils.PrintJSON(map[string]interface{}{
"pool_txid": poolTxID,
})
}
func coinSelect(vtxos []vtxo, amount uint64, sortByExpirationTime bool, dust uint64) ([]vtxo, uint64, error) {
selected := make([]vtxo, 0)
notSelected := make([]vtxo, 0)
selectedAmount := uint64(0)
if sortByExpirationTime {
// 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)
break
}
selected = append(selected, vtxo)
selectedAmount += vtxo.amount
}
if selectedAmount < amount {
return nil, 0, fmt.Errorf("not enough funds to cover amount %d", amount)
}
change := selectedAmount - amount
if change < dust {
if len(notSelected) > 0 {
selected = append(selected, notSelected[0])
change += notSelected[0].amount
}
}
return selected, change, nil
}

View File

@@ -1,158 +0,0 @@
package covenant
import (
"bytes"
"fmt"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common/tree"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v2"
"github.com/vulpemventures/go-elements/psetv2"
"github.com/vulpemventures/go-elements/transaction"
)
func signPset(
ctx *cli.Context, pset *psetv2.Pset, explorer utils.Explorer, prvKey *secp256k1.PrivateKey,
) error {
updater, err := psetv2.NewUpdater(pset)
if err != nil {
return err
}
for i, input := range pset.Inputs {
if input.WitnessUtxo != nil {
continue
}
prevoutTxHex, err := explorer.GetTxHex(chainhash.Hash(input.PreviousTxid).String())
if err != nil {
return err
}
prevoutTx, err := transaction.NewTxFromHex(prevoutTxHex)
if err != nil {
return err
}
utxo := prevoutTx.Outputs[input.PreviousTxIndex]
if utxo == nil {
return fmt.Errorf("witness utxo not found")
}
if err := updater.AddInWitnessUtxo(i, utxo); err != nil {
return err
}
if err := updater.AddInSighashType(i, txscript.SigHashDefault); err != nil {
return err
}
}
signer, err := psetv2.NewSigner(updater.Pset)
if err != nil {
return err
}
utx, err := pset.UnsignedTx()
if err != nil {
return err
}
net, err := utils.GetNetwork(ctx)
if err != nil {
return err
}
prevoutsScripts := make([][]byte, 0)
prevoutsValues := make([][]byte, 0)
prevoutsAssets := make([][]byte, 0)
for _, input := range pset.Inputs {
prevoutsScripts = append(prevoutsScripts, input.WitnessUtxo.Script)
prevoutsValues = append(prevoutsValues, input.WitnessUtxo.Value)
prevoutsAssets = append(prevoutsAssets, input.WitnessUtxo.Asset)
}
liquidNet := toElementsNetwork(net)
for i, input := range pset.Inputs {
if len(input.TapLeafScript) > 0 {
genesis, err := chainhash.NewHashFromStr(liquidNet.GenesisBlockHash)
if err != nil {
return err
}
pubkey := prvKey.PubKey()
for _, leaf := range input.TapLeafScript {
closure, err := tree.DecodeClosure(leaf.Script)
if err != nil {
return err
}
sign := false
switch c := closure.(type) {
case *tree.CSVSigClosure:
sign = bytes.Equal(c.Pubkey.SerializeCompressed()[1:], pubkey.SerializeCompressed()[1:])
case *tree.ForfeitClosure:
sign = bytes.Equal(c.Pubkey.SerializeCompressed()[1:], pubkey.SerializeCompressed()[1:])
}
if sign {
hash := leaf.TapHash()
preimage := utx.HashForWitnessV1(
i,
prevoutsScripts,
prevoutsAssets,
prevoutsValues,
txscript.SigHashDefault,
genesis,
&hash,
nil,
)
sig, err := schnorr.Sign(
prvKey,
preimage[:],
)
if err != nil {
return err
}
tapScriptSig := psetv2.TapScriptSig{
PartialSig: psetv2.PartialSig{
PubKey: schnorr.SerializePubKey(prvKey.PubKey()),
Signature: sig.Serialize(),
},
LeafHash: hash.CloneBytes(),
}
if err := signer.SignTaprootInputTapscriptSig(i, tapScriptSig); err != nil {
return err
}
}
}
}
}
for i, input := range pset.Inputs {
if len(input.PartialSigs) > 0 {
valid, err := pset.ValidateInputSignatures(i)
if err != nil {
return err
}
if !valid {
return fmt.Errorf("invalid signature for input %d", i)
}
}
}
return nil
}

View File

@@ -1,45 +0,0 @@
package covenant
import (
"github.com/ark-network/ark/common/tree"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/vulpemventures/go-elements/taproot"
)
func computeVtxoTaprootScript(
userPubkey, aspPubkey *secp256k1.PublicKey, exitDelay uint,
) (*secp256k1.PublicKey, *taproot.TapscriptElementsProof, error) {
redeemClosure := &tree.CSVSigClosure{
Pubkey: userPubkey,
Seconds: exitDelay,
}
forfeitClosure := &tree.ForfeitClosure{
Pubkey: userPubkey,
AspPubkey: aspPubkey,
}
redeemLeaf, err := redeemClosure.Leaf()
if err != nil {
return nil, nil, err
}
forfeitLeaf, err := forfeitClosure.Leaf()
if err != nil {
return nil, nil, err
}
vtxoTaprootTree := taproot.AssembleTaprootScriptTree(
*redeemLeaf, *forfeitLeaf,
)
root := vtxoTaprootTree.RootNode.TapHash()
unspendableKey := tree.UnspendableKey()
vtxoTaprootKey := taproot.ComputeTaprootOutputKey(unspendableKey, root[:])
redeemLeafHash := redeemLeaf.TapHash()
proofIndex := vtxoTaprootTree.LeafProofIndex[redeemLeafHash]
proof := vtxoTaprootTree.LeafMerkleProofs[proofIndex]
return vtxoTaprootKey, &proof, nil
}

View File

@@ -1,225 +0,0 @@
package covenantless
import (
"fmt"
"math"
"sync"
"time"
arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1"
"github.com/ark-network/ark/client/flags"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common/descriptor"
"github.com/urfave/cli/v2"
)
func (*clArkBitcoinCLI) Balance(ctx *cli.Context) error {
computeExpiryDetails := ctx.Bool(flags.ExpiryDetailsFlag.Name)
client, cancel, err := getClientFromState(ctx)
if err != nil {
return err
}
defer cancel()
offchainAddr, boardingAddr, redemptionAddr, err := getAddress(ctx)
if err != nil {
return err
}
// No need to check for error here becuase this function is called also by getAddress().
// nolint:all
unilateralExitDelay, _ := utils.GetUnilateralExitDelay(ctx)
boardingDescriptor, err := utils.GetBoardingDescriptor(ctx)
if err != nil {
return err
}
desc, err := descriptor.ParseTaprootDescriptor(boardingDescriptor)
if err != nil {
return err
}
_, timeoutBoarding, err := descriptor.ParseBoardingDescriptor(*desc)
if err != nil {
return err
}
wg := &sync.WaitGroup{}
wg.Add(3)
chRes := make(chan balanceRes, 3)
go func() {
defer wg.Done()
explorer := utils.NewExplorer(ctx)
balance, amountByExpiration, err := getOffchainBalance(
ctx, explorer, client, offchainAddr, computeExpiryDetails,
)
if err != nil {
chRes <- balanceRes{0, 0, nil, nil, err}
return
}
chRes <- balanceRes{balance, 0, nil, amountByExpiration, nil}
}()
go func() {
defer wg.Done()
explorer := utils.NewExplorer(ctx)
balance, lockedBalance, err := explorer.GetDelayedBalance(boardingAddr.EncodeAddress(), int64(timeoutBoarding))
if err != nil {
chRes <- balanceRes{0, 0, nil, nil, err}
return
}
chRes <- balanceRes{0, balance, lockedBalance, nil, nil}
}()
go func() {
defer wg.Done()
explorer := utils.NewExplorer(ctx)
spendableBalance, lockedBalance, err := explorer.GetDelayedBalance(
redemptionAddr.EncodeAddress(), unilateralExitDelay,
)
if err != nil {
chRes <- balanceRes{0, 0, nil, nil, err}
return
}
chRes <- balanceRes{0, spendableBalance, lockedBalance, nil, err}
}()
wg.Wait()
lockedOnchainBalance := []map[string]interface{}{}
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 {
return res.err
}
if res.offchainBalance > 0 {
offchainBalance = res.offchainBalance
}
if res.onchainSpendableBalance > 0 {
onchainBalance += res.onchainSpendableBalance
}
if res.offchainBalanceByExpiration != nil {
for timestamp, amount := range res.offchainBalanceByExpiration {
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,
},
)
}
}
if res.onchainLockedBalance != nil {
for timestamp, amount := range res.onchainLockedBalance {
fancyTime := time.Unix(timestamp, 0).Format("2006-01-02 15:04:05")
lockedOnchainBalance = append(
lockedOnchainBalance,
map[string]interface{}{
"spendable_at": fancyTime,
"amount": amount,
},
)
}
}
count++
if count == 3 {
break
}
}
response := make(map[string]interface{})
response["onchain_balance"] = map[string]interface{}{
"spendable_amount": onchainBalance,
}
if len(lockedOnchainBalance) > 0 {
response["onchain_balance"].(map[string]interface{})["locked_amount"] = lockedOnchainBalance
}
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
}
offchainBalanceJSON["details"] = details
response["offchain_balance"] = offchainBalanceJSON
return utils.PrintJSON(response)
}
type balanceRes struct {
offchainBalance uint64
onchainSpendableBalance uint64
onchainLockedBalance map[int64]uint64
offchainBalanceByExpiration map[int64]uint64
err error
}
func getOffchainBalance(
ctx *cli.Context, explorer utils.Explorer, client arkv1.ArkServiceClient,
addr string, computeExpiration bool,
) (uint64, map[int64]uint64, error) {
amountByExpiration := make(map[int64]uint64, 0)
vtxos, err := getVtxos(ctx, explorer, client, addr, computeExpiration)
if err != nil {
return 0, nil, err
}
var balance uint64
for _, vtxo := range vtxos {
balance += vtxo.amount
if vtxo.expireAt != nil {
expiration := vtxo.expireAt.Unix()
if _, ok := amountByExpiration[expiration]; !ok {
amountByExpiration[expiration] = 0
}
amountByExpiration[expiration] += vtxo.amount
}
}
return balance, amountByExpiration, nil
}

View File

@@ -1,187 +0,0 @@
package covenantless
import (
"encoding/hex"
"fmt"
"time"
arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common/descriptor"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v2"
)
func (c *clArkBitcoinCLI) Claim(ctx *cli.Context) error {
client, cancel, err := getClientFromState(ctx)
if err != nil {
return err
}
defer cancel()
offchainAddr, boardingAddr, _, err := getAddress(ctx)
if err != nil {
return err
}
boardingDescriptor, err := utils.GetBoardingDescriptor(ctx)
if err != nil {
return err
}
desc, err := descriptor.ParseTaprootDescriptor(boardingDescriptor)
if err != nil {
return err
}
_, timeoutBoarding, err := descriptor.ParseBoardingDescriptor(*desc)
if err != nil {
return err
}
explorer := utils.NewExplorer(ctx)
boardingUtxosFromExplorer, err := explorer.GetUtxos(boardingAddr.EncodeAddress())
if err != nil {
return err
}
now := time.Now()
boardingUtxos := make([]utils.Utxo, 0, len(boardingUtxosFromExplorer))
for _, utxo := range boardingUtxosFromExplorer {
u := utils.NewUtxo(utxo, uint(timeoutBoarding))
if u.SpendableAt.Before(now) {
continue // cannot claim if onchain spendable
}
boardingUtxos = append(boardingUtxos, u)
}
vtxos, err := getVtxos(ctx, nil, client, offchainAddr, false)
if err != nil {
return err
}
var pendingBalance uint64
var pendingVtxos []vtxo
for _, vtxo := range vtxos {
if vtxo.pending {
pendingBalance += vtxo.amount
pendingVtxos = append(pendingVtxos, vtxo)
}
}
for _, utxo := range boardingUtxos {
pendingBalance += utxo.Amount
}
if pendingBalance == 0 {
return nil
}
receiver := receiver{
To: offchainAddr,
Amount: pendingBalance,
}
if len(ctx.String("password")) == 0 {
if ok := askForConfirmation(
fmt.Sprintf(
"claim %d satoshis from %d pending payments and %d boarding utxos",
pendingBalance, len(pendingVtxos), len(boardingUtxos),
),
); !ok {
return nil
}
}
return selfTransferAllPendingPayments(
ctx, client, pendingVtxos, boardingUtxos, receiver, boardingDescriptor,
)
}
func selfTransferAllPendingPayments(
ctx *cli.Context,
client arkv1.ArkServiceClient,
pendingVtxos []vtxo,
boardingUtxos []utils.Utxo,
myself receiver,
desc string,
) error {
inputs := make([]*arkv1.Input, 0, len(pendingVtxos)+len(boardingUtxos))
for _, coin := range pendingVtxos {
inputs = append(inputs, &arkv1.Input{
Input: &arkv1.Input_VtxoInput{
VtxoInput: &arkv1.VtxoInput{
Txid: coin.txid,
Vout: coin.vout,
},
},
})
}
if len(boardingUtxos) > 0 {
for _, outpoint := range boardingUtxos {
inputs = append(inputs, &arkv1.Input{
Input: &arkv1.Input_BoardingInput{
BoardingInput: &arkv1.BoardingInput{
Txid: outpoint.Txid,
Vout: outpoint.Vout,
Descriptor_: desc,
},
},
})
}
}
receiversOutput := []*arkv1.Output{
{
Address: myself.To,
Amount: myself.Amount,
},
}
secKey, err := utils.PrivateKeyFromPassword(ctx)
if err != nil {
return err
}
ephemeralKey, err := secp256k1.GeneratePrivateKey()
if err != nil {
return err
}
pubkey := hex.EncodeToString(ephemeralKey.PubKey().SerializeCompressed())
registerResponse, err := client.RegisterPayment(
ctx.Context,
&arkv1.RegisterPaymentRequest{
Inputs: inputs,
EphemeralPubkey: &pubkey,
},
)
if err != nil {
return err
}
_, err = client.ClaimPayment(ctx.Context, &arkv1.ClaimPaymentRequest{
Id: registerResponse.GetId(),
Outputs: []*arkv1.Output{{Address: myself.To, Amount: myself.Amount}},
})
if err != nil {
return err
}
poolTxID, err := handleRoundStream(
ctx, client, registerResponse.GetId(), pendingVtxos,
len(boardingUtxos) > 0, secKey, receiversOutput, ephemeralKey,
)
if err != nil {
return err
}
return utils.PrintJSON(map[string]interface{}{
"pool_txid": poolTxID,
})
}

View File

@@ -1,473 +0,0 @@
package covenantless
import (
"fmt"
"math"
"time"
"github.com/ark-network/ark/client/interfaces"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common"
"github.com/ark-network/ark/common/bitcointree"
"github.com/ark-network/ark/common/descriptor"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v2"
)
type clArkBitcoinCLI struct{}
func (c *clArkBitcoinCLI) Receive(ctx *cli.Context) error {
offchainAddr, boardingAddr, _, err := getAddress(ctx)
if err != nil {
return err
}
return utils.PrintJSON(map[string]interface{}{
"offchain_address": offchainAddr,
"boarding_address": boardingAddr.EncodeAddress(),
})
}
func (c *clArkBitcoinCLI) Redeem(ctx *cli.Context) error {
addr := ctx.String("address")
amount := ctx.Uint64("amount")
force := ctx.Bool("force")
if len(addr) <= 0 && !force {
return fmt.Errorf("missing address flag (--address)")
}
if !force && amount <= 0 {
return fmt.Errorf("missing amount flag (--amount)")
}
client, clean, err := getClientFromState(ctx)
if err != nil {
return err
}
defer clean()
if force {
if amount > 0 {
fmt.Printf("WARNING: unilateral exit (--force) ignores --amount flag, it will redeem all your VTXOs\n")
}
return unilateralRedeem(ctx, client)
}
return collaborativeRedeem(ctx, client, addr, amount)
}
func New() interfaces.CLI {
return &clArkBitcoinCLI{}
}
func (c *clArkBitcoinCLI) Send(ctx *cli.Context) error {
return fmt.Errorf("not implemented")
}
type receiver struct {
To string `json:"to"`
Amount uint64 `json:"amount"`
}
func sendOnchain(ctx *cli.Context, receivers []receiver) (string, error) {
ptx, err := psbt.New(nil, nil, 2, 0, nil)
if err != nil {
return "", err
}
updater, err := psbt.NewUpdater(ptx)
if err != nil {
return "", err
}
net, err := utils.GetNetwork(ctx)
if err != nil {
return "", err
}
dust, err := utils.GetDust(ctx)
if err != nil {
return "", err
}
netParams := toChainParams(net)
targetAmount := uint64(0)
for _, receiver := range receivers {
targetAmount += receiver.Amount
if receiver.Amount < dust {
return "", fmt.Errorf("invalid amount (%d), must be greater than dust %d", receiver.Amount, dust)
}
rcvAddr, err := btcutil.DecodeAddress(receiver.To, &netParams)
if err != nil {
return "", err
}
pkscript, err := txscript.PayToAddrScript(rcvAddr)
if err != nil {
return "", err
}
updater.Upsbt.UnsignedTx.AddTxOut(&wire.TxOut{
Value: int64(receiver.Amount),
PkScript: pkscript,
})
updater.Upsbt.Outputs = append(updater.Upsbt.Outputs, psbt.POutput{})
}
explorer := utils.NewExplorer(ctx)
utxos, change, err := coinSelectOnchain(
ctx, explorer, targetAmount, nil,
)
if err != nil {
return "", err
}
if err := addInputs(ctx, updater, utxos); err != nil {
return "", err
}
if change > 0 {
_, changeAddr, _, err := getAddress(ctx)
if err != nil {
return "", err
}
pkscript, err := txscript.PayToAddrScript(changeAddr)
if err != nil {
return "", err
}
updater.Upsbt.UnsignedTx.AddTxOut(&wire.TxOut{
Value: int64(change),
PkScript: pkscript,
})
updater.Upsbt.Outputs = append(updater.Upsbt.Outputs, psbt.POutput{})
}
size := updater.Upsbt.UnsignedTx.SerializeSize()
feeRate, err := explorer.GetFeeRate()
if err != nil {
return "", err
}
feeAmount := uint64(math.Ceil(float64(size)*feeRate) + 50)
if change > feeAmount {
updater.Upsbt.UnsignedTx.TxOut[len(updater.Upsbt.Outputs)-1].Value = int64(change - feeAmount)
} else if change == feeAmount {
updater.Upsbt.UnsignedTx.TxOut = updater.Upsbt.UnsignedTx.TxOut[:len(updater.Upsbt.UnsignedTx.TxOut)-1]
} else { // change < feeAmount
if change > 0 {
updater.Upsbt.UnsignedTx.TxOut = updater.Upsbt.UnsignedTx.TxOut[:len(updater.Upsbt.UnsignedTx.TxOut)-1]
}
// reselect the difference
selected, newChange, err := coinSelectOnchain(
ctx, explorer, feeAmount-change, utxos,
)
if err != nil {
return "", err
}
if err := addInputs(ctx, updater, selected); err != nil {
return "", err
}
if newChange > 0 {
_, changeAddr, _, err := getAddress(ctx)
if err != nil {
return "", err
}
pkscript, err := txscript.PayToAddrScript(changeAddr)
if err != nil {
return "", err
}
updater.Upsbt.UnsignedTx.AddTxOut(&wire.TxOut{
Value: int64(newChange),
PkScript: pkscript,
})
updater.Upsbt.Outputs = append(updater.Upsbt.Outputs, psbt.POutput{})
}
}
prvKey, err := utils.PrivateKeyFromPassword(ctx)
if err != nil {
return "", err
}
if err := signPsbt(ctx, updater.Upsbt, explorer, prvKey); err != nil {
return "", err
}
for i := range updater.Upsbt.Inputs {
if err := psbt.Finalize(updater.Upsbt, i); err != nil {
return "", err
}
}
return updater.Upsbt.B64Encode()
}
func coinSelectOnchain(
ctx *cli.Context,
explorer utils.Explorer, targetAmount uint64, exclude []utils.Utxo,
) ([]utils.Utxo, uint64, error) {
_, boardingAddr, redemptionAddr, err := getAddress(ctx)
if err != nil {
return nil, 0, err
}
boardingUtxosFromExplorer, err := explorer.GetUtxos(boardingAddr.EncodeAddress())
if err != nil {
return nil, 0, err
}
utxos := make([]utils.Utxo, 0)
selectedAmount := uint64(0)
now := time.Now()
boardingDescriptor, err := utils.GetBoardingDescriptor(ctx)
if err != nil {
return nil, 0, err
}
desc, err := descriptor.ParseTaprootDescriptor(boardingDescriptor)
if err != nil {
return nil, 0, err
}
_, timeoutBoarding, err := descriptor.ParseBoardingDescriptor(*desc)
if err != nil {
return nil, 0, err
}
for _, utxo := range boardingUtxosFromExplorer {
if selectedAmount >= targetAmount {
break
}
for _, excluded := range exclude {
if utxo.Txid == excluded.Txid && utxo.Vout == excluded.Vout {
continue
}
}
utxo := utils.NewUtxo(utxo, uint(timeoutBoarding))
if utxo.SpendableAt.After(now) {
utxos = append(utxos, utxo)
selectedAmount += utxo.Amount
}
}
if selectedAmount >= targetAmount {
return utxos, selectedAmount - targetAmount, nil
}
redemptionUtxosFromExplorer, err := explorer.GetUtxos(redemptionAddr.EncodeAddress())
if err != nil {
return nil, 0, err
}
vtxoExitDelay, err := utils.GetUnilateralExitDelay(ctx)
if err != nil {
return nil, 0, err
}
for _, utxo := range redemptionUtxosFromExplorer {
if selectedAmount >= targetAmount {
break
}
for _, excluded := range exclude {
if utxo.Txid == excluded.Txid && utxo.Vout == excluded.Vout {
continue
}
}
utxo := utils.NewUtxo(utxo, uint(vtxoExitDelay))
if utxo.SpendableAt.After(now) {
utxos = append(utxos, utxo)
selectedAmount += utxo.Amount
}
}
if selectedAmount < targetAmount {
return nil, 0, fmt.Errorf(
"not enough funds to cover amount %d", targetAmount,
)
}
return utxos, selectedAmount - targetAmount, nil
}
func addInputs(
ctx *cli.Context,
updater *psbt.Updater,
utxos []utils.Utxo,
) error {
userPubkey, err := utils.GetWalletPublicKey(ctx)
if err != nil {
return err
}
aspPubkey, err := utils.GetAspPublicKey(ctx)
if err != nil {
return err
}
for _, utxo := range utxos {
previousHash, err := chainhash.NewHashFromStr(utxo.Txid)
if err != nil {
return err
}
sequence, err := utxo.Sequence()
if err != nil {
return err
}
updater.Upsbt.UnsignedTx.AddTxIn(&wire.TxIn{
PreviousOutPoint: wire.OutPoint{
Hash: *previousHash,
Index: utxo.Vout,
},
Sequence: sequence,
})
_, leafProof, err := computeVtxoTaprootScript(
userPubkey, aspPubkey, utxo.Delay,
)
if err != nil {
return err
}
controlBlock := leafProof.ToControlBlock(bitcointree.UnspendableKey())
controlBlockBytes, err := controlBlock.ToBytes()
if err != nil {
return err
}
updater.Upsbt.Inputs = append(updater.Upsbt.Inputs, psbt.PInput{
TaprootLeafScript: []*psbt.TaprootTapLeafScript{
{
ControlBlock: controlBlockBytes,
Script: leafProof.Script,
LeafVersion: leafProof.LeafVersion,
},
},
})
}
return nil
}
func decodeReceiverAddress(addr string) (
bool, []byte, *secp256k1.PublicKey, error,
) {
decoded, err := btcutil.DecodeAddress(addr, nil)
if err != nil {
_, userPubkey, _, err := common.DecodeAddress(addr)
if err != nil {
return false, nil, nil, err
}
return false, nil, userPubkey, nil
}
pkscript, err := txscript.PayToAddrScript(decoded)
if err != nil {
return false, nil, nil, err
}
return true, pkscript, nil, nil
}
func getAddress(ctx *cli.Context) (offchainAddr string, boardingAddr, redemptionAddr btcutil.Address, err error) {
userPubkey, err := utils.GetWalletPublicKey(ctx)
if err != nil {
return
}
aspPubkey, err := utils.GetAspPublicKey(ctx)
if err != nil {
return
}
unilateralExitDelay, err := utils.GetUnilateralExitDelay(ctx)
if err != nil {
return
}
boardingDescriptor, err := utils.GetBoardingDescriptor(ctx)
if err != nil {
return
}
desc, err := descriptor.ParseTaprootDescriptor(boardingDescriptor)
if err != nil {
return
}
_, timeoutBoarding, err := descriptor.ParseBoardingDescriptor(*desc)
if err != nil {
return
}
arkNet, err := utils.GetNetwork(ctx)
if err != nil {
return
}
arkAddr, err := common.EncodeAddress(arkNet.Addr, userPubkey, aspPubkey)
if err != nil {
return
}
netParams := toChainParams(arkNet)
vtxoTapKey, _, err := computeVtxoTaprootScript(
userPubkey, aspPubkey, uint(unilateralExitDelay),
)
if err != nil {
return
}
redemptionP2TR, err := btcutil.NewAddressTaproot(
schnorr.SerializePubKey(vtxoTapKey),
&netParams,
)
if err != nil {
return
}
boardingTapKey, _, err := computeVtxoTaprootScript(
userPubkey, aspPubkey, uint(timeoutBoarding),
)
if err != nil {
return
}
boardingP2TR, err := btcutil.NewAddressTaproot(
schnorr.SerializePubKey(boardingTapKey),
&netParams,
)
redemptionAddr = redemptionP2TR
boardingAddr = boardingP2TR
offchainAddr = arkAddr
return
}

View File

@@ -1,660 +0,0 @@
package covenantless
import (
"bytes"
"context"
"encoding/hex"
"fmt"
"io"
"strings"
"time"
arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common/bitcointree"
"github.com/ark-network/ark/common/tree"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/txscript"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v2"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
type vtxo struct {
amount uint64
txid string
vout uint32
poolTxid string
expireAt *time.Time
pending bool
}
func getVtxos(
ctx *cli.Context, explorer utils.Explorer, client arkv1.ArkServiceClient,
addr string, computeExpiration bool,
) ([]vtxo, error) {
response, err := client.ListVtxos(ctx.Context, &arkv1.ListVtxosRequest{
Address: addr,
})
if err != nil {
return nil, err
}
vtxos := make([]vtxo, 0, len(response.GetSpendableVtxos()))
for _, v := range response.GetSpendableVtxos() {
var expireAt *time.Time
if v.GetExpireAt() > 0 {
t := time.Unix(v.ExpireAt, 0)
expireAt = &t
}
if v.GetSwept() {
continue
}
if v.Outpoint.GetVtxoInput() != nil {
vtxos = append(vtxos, vtxo{
amount: v.Receiver.Amount,
txid: v.Outpoint.GetVtxoInput().GetTxid(),
vout: v.Outpoint.GetVtxoInput().GetVout(),
poolTxid: v.PoolTxid,
expireAt: expireAt,
pending: v.GetPending(),
})
}
}
if !computeExpiration {
return vtxos, nil
}
redeemBranches, err := getRedeemBranches(ctx.Context, explorer, client, vtxos)
if err != nil {
return nil, err
}
for vtxoTxid, branch := range redeemBranches {
expiration, err := branch.expireAt(ctx)
if err != nil {
return nil, err
}
for i, vtxo := range vtxos {
if vtxo.txid == vtxoTxid {
vtxos[i].expireAt = expiration
break
}
}
}
return vtxos, nil
}
func getClientFromState(ctx *cli.Context) (arkv1.ArkServiceClient, func(), error) {
state, err := utils.GetState(ctx)
if err != nil {
return nil, nil, err
}
addr := state[utils.ASP_URL]
if len(addr) <= 0 {
return nil, nil, fmt.Errorf("missing asp url")
}
return getClient(addr)
}
func getClient(addr string) (arkv1.ArkServiceClient, func(), error) {
creds := insecure.NewCredentials()
port := 80
addr = strings.TrimPrefix(addr, "http://")
if strings.HasPrefix(addr, "https://") {
addr = strings.TrimPrefix(addr, "https://")
creds = credentials.NewTLS(nil)
port = 443
}
if !strings.Contains(addr, ":") {
addr = fmt.Sprintf("%s:%d", addr, port)
}
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(creds))
if err != nil {
return nil, nil, err
}
client := arkv1.NewArkServiceClient(conn)
closeFn := func() {
err := conn.Close()
if err != nil {
fmt.Printf("error closing connection: %s\n", err)
}
}
return client, closeFn, nil
}
func getRedeemBranches(
ctx context.Context, explorer utils.Explorer, client arkv1.ArkServiceClient,
vtxos []vtxo,
) (map[string]*redeemBranch, error) {
congestionTrees := make(map[string]tree.CongestionTree, 0)
redeemBranches := make(map[string]*redeemBranch, 0)
for _, vtxo := range vtxos {
if _, ok := congestionTrees[vtxo.poolTxid]; !ok {
round, err := client.GetRound(ctx, &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(
explorer, congestionTrees[vtxo.poolTxid], vtxo,
)
if err != nil {
return nil, err
}
redeemBranches[vtxo.txid] = redeemBranch
}
return redeemBranches, nil
}
func toCongestionTree(treeFromProto *arkv1.Tree) (tree.CongestionTree, error) {
levels := make(tree.CongestionTree, 0, len(treeFromProto.Levels))
for _, level := range treeFromProto.Levels {
nodes := make([]tree.Node, 0, len(level.Nodes))
for _, node := range level.Nodes {
nodes = append(nodes, tree.Node{
Txid: node.Txid,
Tx: node.Tx,
ParentTxid: node.ParentTxid,
Leaf: false,
})
}
levels = append(levels, nodes)
}
for j, treeLvl := range levels {
for i, node := range treeLvl {
if len(levels.Children(node.Txid)) == 0 {
levels[j][i].Leaf = true
}
}
}
return levels, nil
}
func handleRoundStream(
ctx *cli.Context, client arkv1.ArkServiceClient, paymentID string,
vtxosToSign []vtxo, mustSignRoundTx bool,
secKey *secp256k1.PrivateKey, receivers []*arkv1.Output,
ephemeralKey *secp256k1.PrivateKey,
) (poolTxID string, err error) {
stream, err := client.GetEventStream(ctx.Context, &arkv1.GetEventStreamRequest{})
if err != nil {
return "", err
}
myEphemeralPublicKey := hex.EncodeToString(ephemeralKey.PubKey().SerializeCompressed())
var pingStop func()
pingReq := &arkv1.PingRequest{
PaymentId: paymentID,
}
for pingStop == nil {
pingStop = ping(ctx.Context, client, pingReq)
}
defer pingStop()
var treeSignerSession bitcointree.SignerSession
for {
event, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
if e := event.GetRoundFailed(); e != nil {
pingStop()
return "", fmt.Errorf("round failed: %s", e.GetReason())
}
if e := event.GetRoundSigning(); e != nil {
pingStop()
fmt.Println("tree created, generating nonces...")
cosignersPubKeysHex := e.GetCosignersPubkeys()
cosigners := make([]*secp256k1.PublicKey, 0, len(cosignersPubKeysHex))
for _, pubkeyHex := range cosignersPubKeysHex {
pubkeyBytes, err := hex.DecodeString(pubkeyHex)
if err != nil {
return "", err
}
pubkey, err := secp256k1.ParsePubKey(pubkeyBytes)
if err != nil {
return "", err
}
cosigners = append(cosigners, pubkey)
}
congestionTree, err := toCongestionTree(e.GetUnsignedTree())
if err != nil {
return "", err
}
aspPubkey, err := utils.GetAspPublicKey(ctx)
if err != nil {
return "", err
}
lifetime, err := utils.GetRoundLifetime(ctx)
if err != nil {
return "", err
}
sweepClosure := bitcointree.CSVSigClosure{
Pubkey: aspPubkey,
Seconds: uint(lifetime),
}
sweepTapLeaf, err := sweepClosure.Leaf()
if err != nil {
return "", err
}
sweepTapTree := txscript.AssembleTaprootScriptTree(*sweepTapLeaf)
root := sweepTapTree.RootNode.TapHash()
roundTx, err := psbt.NewFromRawBytes(strings.NewReader(e.GetUnsignedRoundTx()), true)
if err != nil {
return "", err
}
sharedOutput := roundTx.UnsignedTx.TxOut[0]
sharedOutputValue := sharedOutput.Value
treeSignerSession = bitcointree.NewTreeSignerSession(
ephemeralKey, sharedOutputValue, congestionTree, root.CloneBytes(),
)
nonces, err := treeSignerSession.GetNonces()
if err != nil {
return "", err
}
if err := treeSignerSession.SetKeys(cosigners); err != nil {
return "", err
}
var nonceBuffer bytes.Buffer
if err := nonces.Encode(&nonceBuffer); err != nil {
return "", err
}
serializedNonces := hex.EncodeToString(nonceBuffer.Bytes())
if _, err := client.SendTreeNonces(ctx.Context, &arkv1.SendTreeNoncesRequest{
RoundId: e.GetId(),
PublicKey: myEphemeralPublicKey,
TreeNonces: serializedNonces,
}); err != nil {
return "", err
}
fmt.Println("nonces sent")
continue
}
if e := event.GetRoundSigningNoncesGenerated(); e != nil {
pingStop()
fmt.Println("nonces generated, signing the tree...")
if treeSignerSession == nil {
return "", fmt.Errorf("tree signer session not set")
}
combinedNoncesBytes, err := hex.DecodeString(e.GetTreeNonces())
if err != nil {
return "", err
}
combinedNonces, err := bitcointree.DecodeNonces(bytes.NewReader(combinedNoncesBytes))
if err != nil {
return "", err
}
if err := treeSignerSession.SetAggregatedNonces(combinedNonces); err != nil {
return "", err
}
sigs, err := treeSignerSession.Sign()
if err != nil {
return "", err
}
var sigBuffer bytes.Buffer
if err := sigs.Encode(&sigBuffer); err != nil {
return "", err
}
serializedSigs := hex.EncodeToString(sigBuffer.Bytes())
if _, err := client.SendTreeSignatures(ctx.Context, &arkv1.SendTreeSignaturesRequest{
RoundId: e.GetId(),
TreeSignatures: serializedSigs,
PublicKey: myEphemeralPublicKey,
}); err != nil {
return "", err
}
fmt.Println("tree signed")
continue
}
if e := event.GetRoundFinalization(); e != nil {
// stop pinging as soon as we receive some forfeit txs
pingStop()
roundTx := e.GetPoolTx()
ptx, err := psbt.NewFromRawBytes(strings.NewReader(roundTx), true)
if err != nil {
return "", err
}
congestionTree, err := toCongestionTree(e.GetCongestionTree())
if err != nil {
return "", err
}
connectors := e.GetConnectors()
aspPubkey, err := utils.GetAspPublicKey(ctx)
if err != nil {
return "", err
}
roundLifetime, err := utils.GetRoundLifetime(ctx)
if err != nil {
return "", err
}
if !isOnchainOnly(receivers) {
if err := bitcointree.ValidateCongestionTree(
congestionTree, roundTx, aspPubkey, int64(roundLifetime),
); err != nil {
return "", err
}
}
// TODO bitcoin validateConnectors
// if err := common.ValidateConnectors(poolTx, connectors); err != nil {
// return "", err
// }
unilateralExitDelay, err := utils.GetUnilateralExitDelay(ctx)
if err != nil {
return "", err
}
for _, receiver := range receivers {
isOnChain, onchainScript, userPubkey, err := decodeReceiverAddress(
receiver.Address,
)
if err != nil {
return "", err
}
if isOnChain {
// collaborative exit case
// search for the output in the pool tx
found := false
for _, output := range ptx.UnsignedTx.TxOut {
if bytes.Equal(output.PkScript, onchainScript) {
if output.Value != int64(receiver.Amount) {
return "", fmt.Errorf(
"invalid collaborative exit output amount: got %d, want %d",
output.Value, receiver.Amount,
)
}
found = true
break
}
}
if !found {
return "", fmt.Errorf(
"collaborative exit output not found: %s", receiver.Address,
)
}
continue
}
// off-chain send case
// search for the output in congestion tree
found := false
// compute the receiver output taproot key
outputTapKey, _, err := computeVtxoTaprootScript(
userPubkey, aspPubkey, uint(unilateralExitDelay),
)
if err != nil {
return "", err
}
leaves := congestionTree.Leaves()
for _, leaf := range leaves {
tx, err := psbt.NewFromRawBytes(strings.NewReader(leaf.Tx), true)
if err != nil {
return "", err
}
for _, output := range tx.UnsignedTx.TxOut {
if len(output.PkScript) == 0 {
continue
}
if bytes.Equal(
output.PkScript[2:], schnorr.SerializePubKey(outputTapKey),
) {
if output.Value != int64(receiver.Amount) {
continue
}
found = true
break
}
}
if found {
break
}
}
if !found {
return "", fmt.Errorf(
"off-chain send output not found: %s", receiver.Address,
)
}
}
fmt.Println("congestion tree validated")
explorer := utils.NewExplorer(ctx)
finalizePaymentRequest := &arkv1.FinalizePaymentRequest{}
if len(vtxosToSign) > 0 {
forfeits := e.GetForfeitTxs()
signedForfeits := make([]string, 0)
fmt.Print("signing forfeit txs... ")
connectorsTxids := make([]string, 0, len(connectors))
for _, connector := range connectors {
p, err := psbt.NewFromRawBytes(strings.NewReader(connector), true)
if err != nil {
return "", err
}
txid := p.UnsignedTx.TxHash().String()
connectorsTxids = append(connectorsTxids, txid)
}
for _, forfeit := range forfeits {
ptx, err := psbt.NewFromRawBytes(strings.NewReader(forfeit), true)
if err != nil {
return "", err
}
for _, input := range ptx.UnsignedTx.TxIn {
inputTxid := input.PreviousOutPoint.Hash.String()
for _, coin := range vtxosToSign {
// check if it contains one of the input to sign
if inputTxid == coin.txid {
// verify that the connector is in the connectors list
connectorTxid := ptx.UnsignedTx.TxIn[0].PreviousOutPoint.Hash.String()
connectorFound := false
for _, txid := range connectorsTxids {
if txid == connectorTxid {
connectorFound = true
break
}
}
if !connectorFound {
return "", fmt.Errorf("connector txid %s not found in the connectors list", connectorTxid)
}
if err := signPsbt(ctx, ptx, explorer, secKey); err != nil {
return "", err
}
signedPset, err := ptx.B64Encode()
if err != nil {
return "", err
}
signedForfeits = append(signedForfeits, signedPset)
}
}
}
}
// if no forfeit txs have been signed, start pinging again and wait for the next round
if len(vtxosToSign) > 0 && len(signedForfeits) == 0 {
fmt.Printf("\nno forfeit txs to sign, waiting for the next round...\n")
pingStop = nil
for pingStop == nil {
pingStop = ping(ctx.Context, client, pingReq)
}
continue
}
fmt.Printf("%d signed\n", len(signedForfeits))
finalizePaymentRequest.SignedForfeitTxs = signedForfeits
}
if mustSignRoundTx {
ptx, err := psbt.NewFromRawBytes(strings.NewReader(roundTx), true)
if err != nil {
return "", err
}
if err := signPsbt(ctx, ptx, explorer, secKey); err != nil {
return "", err
}
signedRoundTx, err := ptx.B64Encode()
if err != nil {
return "", err
}
fmt.Println("round tx signed")
finalizePaymentRequest.SignedRoundTx = &signedRoundTx
}
fmt.Print("finalizing payment... ")
_, err = client.FinalizePayment(ctx.Context, finalizePaymentRequest)
if err != nil {
return "", err
}
fmt.Print("done.\n")
fmt.Println("waiting for round finalization...")
continue
}
if event.GetRoundFinalized() != nil {
return event.GetRoundFinalized().GetPoolTxid(), nil
}
}
return "", fmt.Errorf("stream closed unexpectedly")
}
// send 1 ping message every 5 seconds to signal to the ark service that we are still alive
// returns a function that can be used to stop the pinging
func ping(
ctx context.Context, client arkv1.ArkServiceClient, req *arkv1.PingRequest,
) func() {
_, err := client.Ping(ctx, req)
if err != nil {
return nil
}
ticker := time.NewTicker(5 * time.Second)
go func(t *time.Ticker) {
for range t.C {
// nolint
client.Ping(ctx, req)
}
}(ticker)
return ticker.Stop
}
func isOnchainOnly(receivers []*arkv1.Output) bool {
for _, receiver := range receivers {
isOnChain, _, _, err := decodeReceiverAddress(receiver.Address)
if err != nil {
continue
}
if !isOnChain {
return false
}
}
return true
}

View File

@@ -1,128 +0,0 @@
package covenantless
import (
"encoding/hex"
"fmt"
"strconv"
"strings"
arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v2"
)
var explorerUrls = map[string]string{
common.Bitcoin.Name: "https://blockstream.info/api",
common.BitcoinTestNet.Name: "https://blockstream.info/testnet/api",
common.BitcoinRegTest.Name: "http://localhost:3000",
common.BitcoinSigNet.Name: "https://mutinynet.com/api",
}
func (c *clArkBitcoinCLI) Init(ctx *cli.Context) error {
key := ctx.String("prvkey")
net := strings.ToLower(ctx.String("network"))
url := ctx.String("asp-url")
explorer := ctx.String("explorer")
var explorerURL string
if len(url) <= 0 {
return fmt.Errorf("invalid ark url")
}
if net != common.Bitcoin.Name && net != common.BitcoinTestNet.Name && net != common.BitcoinRegTest.Name && net != common.BitcoinSigNet.Name {
return fmt.Errorf("invalid network")
}
if len(explorer) > 0 {
explorerURL = explorer
} else {
explorerURL = explorerUrls[net]
}
if err := connectToAsp(ctx, net, url, explorerURL); err != nil {
return err
}
password, err := utils.ReadPassword(ctx, false)
if err != nil {
return err
}
return initWallet(ctx, key, password)
}
func generateRandomPrivateKey() (*secp256k1.PrivateKey, error) {
privKey, err := btcec.NewPrivateKey()
if err != nil {
return nil, err
}
return privKey, nil
}
func connectToAsp(ctx *cli.Context, net, url, explorer string) error {
client, close, err := getClient(url)
if err != nil {
return err
}
defer close()
resp, err := client.GetInfo(ctx.Context, &arkv1.GetInfoRequest{})
if err != nil {
return err
}
return utils.SetState(ctx, map[string]string{
utils.ASP_URL: url,
utils.NETWORK: net,
utils.ASP_PUBKEY: resp.Pubkey,
utils.ROUND_LIFETIME: strconv.Itoa(int(resp.GetRoundLifetime())),
utils.UNILATERAL_EXIT_DELAY: strconv.Itoa(int(resp.GetUnilateralExitDelay())),
utils.EXPLORER: explorer,
utils.DUST: strconv.Itoa(int(resp.GetDust())),
utils.BOARDING_TEMPLATE: resp.GetBoardingDescriptorTemplate(),
})
}
func initWallet(ctx *cli.Context, key string, password []byte) error {
var privateKey *secp256k1.PrivateKey
if len(key) <= 0 {
privKey, err := generateRandomPrivateKey()
if err != nil {
return err
}
privateKey = privKey
} else {
privKeyBytes, err := hex.DecodeString(key)
if err != nil {
return err
}
privateKey = secp256k1.PrivKeyFromBytes(privKeyBytes)
}
cypher := utils.NewAES128Cypher()
buf := privateKey.Serialize()
encryptedPrivateKey, err := cypher.Encrypt(buf, password)
if err != nil {
return err
}
passwordHash := utils.HashPassword([]byte(password))
pubkey := privateKey.PubKey().SerializeCompressed()
state := map[string]string{
utils.ENCRYPTED_PRVKEY: hex.EncodeToString(encryptedPrivateKey),
utils.PASSWORD_HASH: hex.EncodeToString(passwordHash),
utils.PUBKEY: hex.EncodeToString(pubkey),
}
if err := utils.SetState(ctx, state); err != nil {
return err
}
fmt.Println("wallet initialized")
return nil
}

View File

@@ -1,24 +0,0 @@
package covenantless
import (
"github.com/ark-network/ark/common"
"github.com/btcsuite/btcd/chaincfg"
)
func toChainParams(net *common.Network) chaincfg.Params {
// we pass nil to have the equivalent of dnssec=0 in bitcoin.conf
mutinyNetSigNetParams := chaincfg.CustomSignetParams(common.MutinyNetChallenge, nil)
mutinyNetSigNetParams.TargetTimePerBlock = common.MutinyNetBlockTime
switch net.Name {
case common.Bitcoin.Name:
return chaincfg.MainNetParams
case common.BitcoinTestNet.Name:
return chaincfg.TestNet3Params
case common.BitcoinRegTest.Name:
return chaincfg.RegressionNetParams
case common.BitcoinSigNet.Name:
return mutinyNetSigNetParams
default:
return chaincfg.MainNetParams
}
}

View File

@@ -1,227 +0,0 @@
package covenantless
import (
"bufio"
"encoding/hex"
"fmt"
"log"
"os"
"strings"
"time"
arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1"
"github.com/ark-network/ark/client/utils"
"github.com/btcsuite/btcd/btcutil"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v2"
)
func collaborativeRedeem(
ctx *cli.Context, client arkv1.ArkServiceClient, addr string, amount uint64,
) error {
withExpiryCoinselect := ctx.Bool("enable-expiry-coinselect")
_, err := btcutil.DecodeAddress(addr, nil)
if err != nil {
return fmt.Errorf("invalid onchain address")
}
netinstate, err := utils.GetNetwork(ctx)
if err != nil {
return err
}
dust, err := utils.GetDust(ctx)
if err != nil {
return err
}
netParams := toChainParams(netinstate)
if netinstate.Name != netParams.Name {
return fmt.Errorf("invalid onchain address: must be for %s network", netParams.Name)
}
offchainAddr, _, _, err := getAddress(ctx)
if err != nil {
return err
}
receivers := []*arkv1.Output{
{
Address: addr,
Amount: amount,
},
}
explorer := utils.NewExplorer(ctx)
vtxos, err := getVtxos(ctx, explorer, client, offchainAddr, withExpiryCoinselect)
if err != nil {
return err
}
selectedCoins, changeAmount, err := coinSelect(vtxos, amount, withExpiryCoinselect, dust)
if err != nil {
return err
}
if changeAmount > 0 {
receivers = append(receivers, &arkv1.Output{
Address: offchainAddr,
Amount: changeAmount,
})
}
inputs := make([]*arkv1.Input, 0, len(selectedCoins))
for _, coin := range selectedCoins {
inputs = append(inputs, &arkv1.Input{
Input: &arkv1.Input_VtxoInput{
VtxoInput: &arkv1.VtxoInput{
Txid: coin.txid,
Vout: coin.vout,
},
},
})
}
secKey, err := utils.PrivateKeyFromPassword(ctx)
if err != nil {
return err
}
ephemeralKey, err := secp256k1.GeneratePrivateKey()
if err != nil {
return err
}
pubkey := hex.EncodeToString(ephemeralKey.PubKey().SerializeCompressed())
registerResponse, err := client.RegisterPayment(ctx.Context, &arkv1.RegisterPaymentRequest{
Inputs: inputs,
EphemeralPubkey: &pubkey,
})
if err != nil {
return err
}
_, err = client.ClaimPayment(ctx.Context, &arkv1.ClaimPaymentRequest{
Id: registerResponse.GetId(),
Outputs: receivers,
})
if err != nil {
return err
}
poolTxID, err := handleRoundStream(
ctx,
client,
registerResponse.GetId(),
selectedCoins,
false,
secKey,
receivers,
ephemeralKey,
)
if err != nil {
return err
}
return utils.PrintJSON(map[string]interface{}{
"pool_txid": poolTxID,
})
}
func unilateralRedeem(ctx *cli.Context, client arkv1.ArkServiceClient) error {
offchainAddr, _, _, err := getAddress(ctx)
if err != nil {
return err
}
explorer := utils.NewExplorer(ctx)
vtxos, err := getVtxos(ctx, explorer, client, offchainAddr, false)
if err != nil {
return err
}
totalVtxosAmount := uint64(0)
for _, vtxo := range vtxos {
totalVtxosAmount += vtxo.amount
}
if len(ctx.String("password")) == 0 {
ok := askForConfirmation(fmt.Sprintf("redeem %d sats ?", totalVtxosAmount))
if !ok {
return fmt.Errorf("aborting unilateral exit")
}
}
// transactionsMap avoid duplicates
transactionsMap := make(map[string]struct{}, 0)
transactions := make([]string, 0)
redeemBranches, err := getRedeemBranches(ctx.Context, explorer, client, vtxos)
if err != nil {
return err
}
for _, branch := range redeemBranches {
branchTxs, err := branch.redeemPath()
if err != nil {
return err
}
for _, txHex := range branchTxs {
if _, ok := transactionsMap[txHex]; !ok {
transactions = append(transactions, txHex)
transactionsMap[txHex] = struct{}{}
}
}
}
for i, txHex := range transactions {
for {
txid, err := explorer.Broadcast(txHex)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "bad-txns-inputs-missingorspent") {
time.Sleep(1 * time.Second)
} else {
fmt.Printf("error broadcasting tx %s: %s\n", txHex, err)
return err
}
}
if len(txid) > 0 {
fmt.Printf("(%d/%d) broadcasted tx %s\n", i+1, len(transactions), txid)
break
}
}
}
return nil
}
// askForConfirmation asks the user for confirmation. A user must type in "yes" or "no" and then press enter.
// if the input is not recognized, it will ask again.
func askForConfirmation(s string) bool {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Printf("%s [y/n]: ", s)
response, err := reader.ReadString('\n')
if err != nil {
log.Fatal(err)
}
response = strings.ToLower(strings.TrimSpace(response))
if response == "y" || response == "yes" {
return true
} else if response == "n" || response == "no" {
return false
}
}
}

View File

@@ -1,194 +0,0 @@
package covenantless
import (
"bytes"
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common/bitcointree"
"github.com/ark-network/ark/common/tree"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/txscript"
"github.com/urfave/cli/v2"
)
type redeemBranch struct {
vtxo *vtxo
branch []*psbt.Packet
lifetime time.Duration
explorer utils.Explorer
}
func newRedeemBranch(
explorer utils.Explorer,
congestionTree tree.CongestionTree, vtxo vtxo,
) (*redeemBranch, error) {
_, seconds, err := findSweepClosure(congestionTree)
if err != nil {
return nil, err
}
lifetime, err := time.ParseDuration(fmt.Sprintf("%ds", seconds))
if err != nil {
return nil, err
}
nodes, err := congestionTree.Branch(vtxo.txid)
if err != nil {
return nil, err
}
branch := make([]*psbt.Packet, 0, len(nodes))
for _, node := range nodes {
ptx, err := psbt.NewFromRawBytes(strings.NewReader(node.Tx), true)
if err != nil {
return nil, err
}
branch = append(branch, ptx)
}
return &redeemBranch{
vtxo: &vtxo,
branch: branch,
lifetime: lifetime,
explorer: explorer,
}, 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))
offchainPath, err := r.offchainPath()
if err != nil {
return nil, err
}
for _, ptx := range offchainPath {
firstInput := ptx.Inputs[0]
if len(firstInput.TaprootKeySpendSig) == 0 {
return nil, fmt.Errorf("missing taproot key spend signature")
}
var witness bytes.Buffer
if err := psbt.WriteTxWitness(&witness, [][]byte{firstInput.TaprootKeySpendSig}); err != nil {
return nil, err
}
ptx.Inputs[0].FinalScriptWitness = witness.Bytes()
extracted, err := psbt.Extract(ptx)
if err != nil {
return nil, err
}
var txBytes bytes.Buffer
if err := extracted.Serialize(&txBytes); err != nil {
return nil, err
}
transactions = append(transactions, hex.EncodeToString(txBytes.Bytes()))
}
return transactions, nil
}
func (r *redeemBranch) expireAt(*cli.Context) (*time.Time, error) {
lastKnownBlocktime := int64(0)
confirmed, blocktime, _ := r.explorer.GetTxBlocktime(r.vtxo.poolTxid)
if confirmed {
lastKnownBlocktime = blocktime
} else {
expirationFromNow := time.Now().Add(time.Minute).Add(r.lifetime)
return &expirationFromNow, nil
}
for _, ptx := range r.branch {
txid := ptx.UnsignedTx.TxHash().String()
confirmed, blocktime, err := r.explorer.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() ([]*psbt.Packet, error) {
offchainPath := append([]*psbt.Packet{}, r.branch...)
for i := len(r.branch) - 1; i >= 0; i-- {
ptx := r.branch[i]
txHash := ptx.UnsignedTx.TxHash().String()
if _, err := r.explorer.GetTxHex(txHash); 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 = []*psbt.Packet{}
} else {
offchainPath = r.branch[i+1:]
}
break
}
return offchainPath, nil
}
func findSweepClosure(
congestionTree tree.CongestionTree,
) (*txscript.TapLeaf, uint, error) {
root, err := congestionTree.Root()
if err != nil {
return nil, 0, err
}
// find the sweep closure
tx, err := psbt.NewFromRawBytes(strings.NewReader(root.Tx), true)
if err != nil {
fmt.Println("find sweep closure error")
return nil, 0, err
}
var seconds uint
var sweepClosure *txscript.TapLeaf
for _, tapLeaf := range tx.Inputs[0].TaprootLeafScript {
closure := &bitcointree.CSVSigClosure{}
valid, err := closure.Decode(tapLeaf.Script)
if err != nil {
continue
}
if valid && closure.Seconds > seconds {
seconds = closure.Seconds
leaf := txscript.NewBaseTapLeaf(tapLeaf.Script)
sweepClosure = &leaf
}
}
if sweepClosure == nil {
return nil, 0, fmt.Errorf("sweep closure not found")
}
return sweepClosure, seconds, nil
}

View File

@@ -1,214 +0,0 @@
package covenantless
import (
"bytes"
"fmt"
"sort"
"strings"
arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/urfave/cli/v2"
)
func (c *clArkBitcoinCLI) SendAsync(ctx *cli.Context) error {
receiverAddr := ctx.String("to")
amount := ctx.Uint64("amount")
withExpiryCoinselect := ctx.Bool("enable-expiry-coinselect")
dust, err := utils.GetDust(ctx)
if err != nil {
return err
}
if amount < dust {
return fmt.Errorf("invalid amount (%d), must be greater than dust %d", amount, dust)
}
if receiverAddr == "" {
return fmt.Errorf("receiver address is required")
}
isOnchain, _, _, err := decodeReceiverAddress(receiverAddr)
if err != nil {
return err
}
if isOnchain {
txid, err := sendOnchain(ctx, []receiver{{receiverAddr, amount}})
if err != nil {
return err
}
return utils.PrintJSON(map[string]interface{}{
"txid": txid,
})
}
offchainAddr, _, _, err := getAddress(ctx)
if err != nil {
return err
}
_, _, aspPubKey, err := common.DecodeAddress(offchainAddr)
if err != nil {
return err
}
_, _, aspKey, err := common.DecodeAddress(receiverAddr)
if err != nil {
return fmt.Errorf("invalid receiver address: %s", err)
}
if !bytes.Equal(
aspPubKey.SerializeCompressed(), aspKey.SerializeCompressed(),
) {
return fmt.Errorf("invalid receiver address '%s': must be associated with the connected service provider", receiverAddr)
}
receiversOutput := make([]*arkv1.Output, 0)
sumOfReceivers := uint64(0)
receiversOutput = append(receiversOutput, &arkv1.Output{
Address: receiverAddr,
Amount: amount,
})
sumOfReceivers += amount
client, close, err := getClientFromState(ctx)
if err != nil {
return err
}
defer close()
explorer := utils.NewExplorer(ctx)
vtxos, err := getVtxos(ctx, explorer, client, offchainAddr, withExpiryCoinselect)
if err != nil {
return err
}
selectedCoins, changeAmount, err := coinSelect(vtxos, sumOfReceivers, withExpiryCoinselect, dust)
if err != nil {
return err
}
if changeAmount > 0 {
changeReceiver := &arkv1.Output{
Address: offchainAddr,
Amount: changeAmount,
}
receiversOutput = append(receiversOutput, changeReceiver)
}
inputs := make([]*arkv1.Input, 0, len(selectedCoins))
for _, coin := range selectedCoins {
inputs = append(inputs, &arkv1.Input{
Input: &arkv1.Input_VtxoInput{
VtxoInput: &arkv1.VtxoInput{
Txid: coin.txid,
Vout: coin.vout,
},
},
})
}
resp, err := client.CreatePayment(
ctx.Context, &arkv1.CreatePaymentRequest{
Inputs: inputs,
Outputs: receiversOutput,
})
if err != nil {
return err
}
// TODO verify the redeem tx signature
fmt.Println("payment created")
fmt.Println("signing redeem and forfeit txs...")
seckey, err := utils.PrivateKeyFromPassword(ctx)
if err != nil {
return err
}
signedUnconditionalForfeitTxs := make([]string, 0, len(resp.UsignedUnconditionalForfeitTxs))
for _, tx := range resp.UsignedUnconditionalForfeitTxs {
forfeitPtx, err := psbt.NewFromRawBytes(strings.NewReader(tx), true)
if err != nil {
return err
}
if err := signPsbt(ctx, forfeitPtx, explorer, seckey); err != nil {
return err
}
signedForfeitTx, err := forfeitPtx.B64Encode()
if err != nil {
return err
}
signedUnconditionalForfeitTxs = append(signedUnconditionalForfeitTxs, signedForfeitTx)
}
redeemPtx, err := psbt.NewFromRawBytes(strings.NewReader(resp.SignedRedeemTx), true)
if err != nil {
return err
}
if err := signPsbt(ctx, redeemPtx, explorer, seckey); err != nil {
return err
}
signedRedeem, err := redeemPtx.B64Encode()
if err != nil {
return err
}
if _, err = client.CompletePayment(ctx.Context, &arkv1.CompletePaymentRequest{
SignedRedeemTx: signedRedeem,
SignedUnconditionalForfeitTxs: signedUnconditionalForfeitTxs,
}); err != nil {
return err
}
fmt.Println("payment completed")
return nil
}
func coinSelect(vtxos []vtxo, amount uint64, sortByExpirationTime bool, dust uint64) ([]vtxo, uint64, error) {
selected := make([]vtxo, 0)
notSelected := make([]vtxo, 0)
selectedAmount := uint64(0)
if sortByExpirationTime {
// 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)
break
}
selected = append(selected, vtxo)
selectedAmount += vtxo.amount
}
if selectedAmount < amount {
return nil, 0, fmt.Errorf("not enough funds to cover amount %d", amount)
}
change := selectedAmount - amount
if change > 0 && change < dust {
if len(notSelected) > 0 {
selected = append(selected, notSelected[0])
change += notSelected[0].amount
}
}
return selected, change, nil
}

View File

@@ -1,133 +0,0 @@
package covenantless
import (
"bytes"
"encoding/hex"
"fmt"
"strings"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common/bitcointree"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v2"
)
func signPsbt(
_ *cli.Context, ptx *psbt.Packet, explorer utils.Explorer, prvKey *secp256k1.PrivateKey,
) error {
updater, err := psbt.NewUpdater(ptx)
if err != nil {
return err
}
for i, input := range updater.Upsbt.UnsignedTx.TxIn {
if updater.Upsbt.Inputs[i].WitnessUtxo != nil {
continue
}
prevoutTxHex, err := explorer.GetTxHex(input.PreviousOutPoint.Hash.String())
if err != nil {
return err
}
var prevoutTx wire.MsgTx
if err := prevoutTx.Deserialize(hex.NewDecoder(strings.NewReader(prevoutTxHex))); err != nil {
return err
}
utxo := prevoutTx.TxOut[input.PreviousOutPoint.Index]
if utxo == nil {
return fmt.Errorf("witness utxo not found")
}
if err := updater.AddInWitnessUtxo(utxo, i); err != nil {
return err
}
if err := updater.AddInSighashType(txscript.SigHashDefault, i); err != nil {
return err
}
}
prevouts := make(map[wire.OutPoint]*wire.TxOut)
for i, input := range updater.Upsbt.Inputs {
outpoint := updater.Upsbt.UnsignedTx.TxIn[i].PreviousOutPoint
prevouts[outpoint] = input.WitnessUtxo
}
prevoutFetcher := txscript.NewMultiPrevOutFetcher(
prevouts,
)
txsighashes := txscript.NewTxSigHashes(updater.Upsbt.UnsignedTx, prevoutFetcher)
for i, input := range ptx.Inputs {
if len(input.TaprootLeafScript) > 0 {
pubkey := prvKey.PubKey()
for _, leaf := range input.TaprootLeafScript {
closure, err := bitcointree.DecodeClosure(leaf.Script)
if err != nil {
return err
}
sign := false
switch c := closure.(type) {
case *bitcointree.CSVSigClosure:
sign = bytes.Equal(c.Pubkey.SerializeCompressed()[1:], pubkey.SerializeCompressed()[1:])
case *bitcointree.MultisigClosure:
sign = bytes.Equal(c.Pubkey.SerializeCompressed()[1:], pubkey.SerializeCompressed()[1:])
}
if sign {
if err := updater.AddInSighashType(txscript.SigHashDefault, i); err != nil {
return err
}
hash := txscript.NewTapLeaf(leaf.LeafVersion, leaf.Script).TapHash()
preimage, err := txscript.CalcTapscriptSignaturehash(
txsighashes,
txscript.SigHashDefault,
ptx.UnsignedTx,
i,
prevoutFetcher,
txscript.NewBaseTapLeaf(leaf.Script),
)
if err != nil {
return err
}
sig, err := schnorr.Sign(
prvKey,
preimage,
)
if err != nil {
return err
}
if !sig.Verify(preimage, prvKey.PubKey()) {
return fmt.Errorf("signature verification failed")
}
updater.Upsbt.Inputs[i].TaprootScriptSpendSig = []*psbt.TaprootScriptSpendSig{
{
XOnlyPubKey: schnorr.SerializePubKey(prvKey.PubKey()),
LeafHash: hash.CloneBytes(),
Signature: sig.Serialize(),
SigHash: txscript.SigHashDefault,
},
}
}
}
}
}
return nil
}

View File

@@ -1,45 +0,0 @@
package covenantless
import (
"github.com/ark-network/ark/common/bitcointree"
"github.com/btcsuite/btcd/txscript"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
func computeVtxoTaprootScript(
userPubkey, aspPubkey *secp256k1.PublicKey, exitDelay uint,
) (*secp256k1.PublicKey, *txscript.TapscriptProof, error) {
redeemClosure := &bitcointree.CSVSigClosure{
Pubkey: userPubkey,
Seconds: exitDelay,
}
forfeitClosure := &bitcointree.MultisigClosure{
Pubkey: userPubkey,
AspPubkey: aspPubkey,
}
redeemLeaf, err := redeemClosure.Leaf()
if err != nil {
return nil, nil, err
}
forfeitLeaf, err := forfeitClosure.Leaf()
if err != nil {
return nil, nil, err
}
vtxoTaprootTree := txscript.AssembleTaprootScriptTree(
*redeemLeaf, *forfeitLeaf,
)
root := vtxoTaprootTree.RootNode.TapHash()
unspendableKey := bitcointree.UnspendableKey()
vtxoTaprootKey := txscript.ComputeTaprootOutputKey(unspendableKey, root[:])
redeemLeafHash := redeemLeaf.TapHash()
proofIndex := vtxoTaprootTree.LeafProofIndex[redeemLeafHash]
proof := vtxoTaprootTree.LeafMerkleProofs[proofIndex]
return vtxoTaprootKey, &proof, nil
}

View File

@@ -1,89 +0,0 @@
package flags
import (
"github.com/ark-network/ark/common"
"github.com/urfave/cli/v2"
)
const DATADIR_ENVVAR = "ARK_WALLET_DATADIR"
var (
DatadirFlag = &cli.StringFlag{
Name: "datadir",
Usage: "Specify the data directory",
Required: false,
Value: common.AppDataDir("ark-cli", false),
EnvVars: []string{DATADIR_ENVVAR},
}
PasswordFlag = cli.StringFlag{
Name: "password",
Usage: "password to unlock the wallet",
Required: false,
Hidden: true,
}
ExpiryDetailsFlag = cli.BoolFlag{
Name: "compute-expiry-details",
Usage: "compute client-side the VTXOs expiry time",
Value: false,
Required: false,
}
PrivateKeyFlag = cli.StringFlag{
Name: "prvkey",
Usage: "optional, private key to encrypt",
}
NetworkFlag = cli.StringFlag{
Name: "network",
Usage: "network to use (liquid, testnet, regtest, signet)",
Value: "liquid",
}
UrlFlag = cli.StringFlag{
Name: "asp-url",
Usage: "the url of the ASP to connect to",
Required: true,
}
ExplorerFlag = cli.StringFlag{
Name: "explorer",
Usage: "the url of the explorer to use",
}
ReceiversFlag = cli.StringFlag{
Name: "receivers",
Usage: "receivers of the send transaction, JSON encoded: '[{\"to\": \"<...>\", \"amount\": <...>}, ...]'",
}
ToFlag = cli.StringFlag{
Name: "to",
Usage: "address of the recipient",
}
AmountFlag = cli.Uint64Flag{
Name: "amount",
Usage: "amount to send in sats",
}
EnableExpiryCoinselectFlag = cli.BoolFlag{
Name: "enable-expiry-coinselect",
Usage: "select vtxos that are about to expire first",
Value: false,
}
AddressFlag = cli.StringFlag{
Name: "address",
Usage: "main chain address receiving the redeeemed VTXO",
Value: "",
Required: false,
}
AmountToRedeemFlag = cli.Uint64Flag{
Name: "amount",
Usage: "amount to redeem",
Value: 0,
Required: false,
}
ForceFlag = cli.BoolFlag{
Name: "force",
Usage: "force redemption without collaborate with the Ark service provider",
Value: false,
Required: false,
}
AsyncPaymentFlag = cli.BoolFlag{
Name: "async",
Usage: "use async payment protocol",
Value: false,
Required: false,
}
)

View File

@@ -5,21 +5,18 @@ go 1.23.1
replace github.com/btcsuite/btcd/btcec/v2 => github.com/btcsuite/btcd/btcec/v2 v2.3.3
require (
github.com/ark-network/ark/api-spec v0.0.0-20240812233307-18e343b31899
github.com/ark-network/ark/common v0.0.0-20240910195127-ab2c9785d00e
github.com/btcsuite/btcd v0.24.2
github.com/btcsuite/btcd/btcec/v2 v2.3.4
github.com/btcsuite/btcd/btcutil/psbt v1.1.9
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0
github.com/urfave/cli/v2 v2.27.4
golang.org/x/crypto v0.26.0
golang.org/x/term v0.23.0
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aead/siphash v1.0.1 // indirect
github.com/btcsuite/btcd v0.24.2 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
github.com/btcsuite/btcd/btcutil/psbt v1.1.9 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect
github.com/btcsuite/btcwallet v0.16.10-0.20240718224643-db3a4a2543bd // indirect
github.com/btcsuite/btcwallet/wallet/txauthor v1.3.4 // indirect
@@ -30,12 +27,15 @@ require (
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/continuity v0.4.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/decred/dcrd/lru v1.1.3 // indirect
github.com/docker/docker v27.1.1+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kkdai/bstream v1.0.0 // indirect
@@ -66,23 +66,18 @@ require (
go.opentelemetry.io/otel v1.30.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect
golang.org/x/sync v0.8.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require (
github.com/btcsuite/btcd/btcutil v1.1.5
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.21.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/vulpemventures/go-elements v0.5.4
github.com/vulpemventures/go-elements v0.5.4 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.17.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240812133136-8ffd90a71988 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240812133136-8ffd90a71988 // indirect
google.golang.org/grpc v1.65.0
google.golang.org/protobuf v1.34.2 // indirect
)

View File

@@ -9,8 +9,6 @@ github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmH
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA=
github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/ark-network/ark/api-spec v0.0.0-20240812233307-18e343b31899 h1:PJL9Pam042F790x3mMovaIIkgeKIVaWm1aFOyH0k4PY=
github.com/ark-network/ark/api-spec v0.0.0-20240812233307-18e343b31899/go.mod h1:0B5seq/gzuGL8OZGUaO12yj73ZJKAde8L+nmLQAZ7IA=
github.com/ark-network/ark/common v0.0.0-20240910195127-ab2c9785d00e h1:v/8OJWtbOP+663V7n4a6JJz19GQ3+zoKxxPtvqB0a94=
github.com/ark-network/ark/common v0.0.0-20240910195127-ab2c9785d00e/go.mod h1:VJQZ+5oRGfMKv4euwgoblBQiI6yBOi9JuNUb3SOpOiU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -124,8 +122,6 @@ github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=

View File

@@ -1,13 +0,0 @@
package interfaces
import "github.com/urfave/cli/v2"
type CLI interface {
Balance(ctx *cli.Context) error
Init(ctx *cli.Context) error
Receive(ctx *cli.Context) error
Redeem(ctx *cli.Context) error
Send(ctx *cli.Context) error
Claim(ctx *cli.Context) error
SendAsync(ctx *cli.Context) error
}

View File

@@ -1,174 +1,59 @@
package main
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
"syscall"
"github.com/ark-network/ark/client/covenant"
"github.com/ark-network/ark/client/covenantless"
"github.com/ark-network/ark/client/flags"
"github.com/ark-network/ark/client/interfaces"
"github.com/ark-network/ark/client/utils"
"github.com/ark-network/ark/common"
arksdk "github.com/ark-network/ark/pkg/client-sdk"
"github.com/ark-network/ark/pkg/client-sdk/store"
filestore "github.com/ark-network/ark/pkg/client-sdk/store/file"
"github.com/urfave/cli/v2"
"golang.org/x/term"
)
const (
DatadirEnvVar = "ARK_WALLET_DATADIR"
)
var (
balanceCommand = cli.Command{
Name: "balance",
Usage: "Shows the onchain and offchain balance of the Ark wallet",
Action: func(ctx *cli.Context) error {
cli, err := getCLIFromState(ctx)
if err != nil {
return err
}
return cli.Balance(ctx)
},
Flags: []cli.Flag{&flags.ExpiryDetailsFlag},
}
configCommand = cli.Command{
Name: "config",
Usage: "Shows configuration of the Ark wallet",
Action: func(ctx *cli.Context) error {
state, err := utils.GetState(ctx)
if err != nil {
return err
}
return utils.PrintJSON(state)
},
}
dumpCommand = cli.Command{
Name: "dump-privkey",
Usage: "Dumps private key of the Ark wallet",
Action: func(ctx *cli.Context) error {
privKey, err := utils.PrivateKeyFromPassword(ctx)
if err != nil {
return err
}
return utils.PrintJSON(map[string]interface{}{
"private_key": hex.EncodeToString(privKey.Serialize()),
})
},
Flags: []cli.Flag{&flags.PasswordFlag},
}
initCommand = cli.Command{
Name: "init",
Usage: "Initialize your Ark wallet with an encryption password, and connect it to an ASP",
Action: func(ctx *cli.Context) error {
cli, err := getCLIFromFlags(ctx)
if err != nil {
return err
}
return cli.Init(ctx)
},
Flags: []cli.Flag{&flags.PasswordFlag, &flags.PrivateKeyFlag, &flags.NetworkFlag, &flags.UrlFlag, &flags.ExplorerFlag},
}
sendCommand = cli.Command{
Name: "send",
Usage: "Send your onchain or offchain funds to one or many receivers",
Action: func(ctx *cli.Context) error {
state, err := utils.GetState(ctx)
if err != nil {
return err
}
networkName := state[utils.NETWORK]
cli, err := getCLI(networkName)
if err != nil {
return err
}
if strings.Contains(networkName, "liquid") {
return cli.Send(ctx)
}
return cli.SendAsync(ctx)
},
Flags: []cli.Flag{&flags.ReceiversFlag, &flags.ToFlag, &flags.AmountFlag, &flags.PasswordFlag, &flags.EnableExpiryCoinselectFlag, &flags.AsyncPaymentFlag},
}
claimCommand = cli.Command{
Name: "claim",
Usage: "Join round to claim pending payments",
Action: func(ctx *cli.Context) error {
cli, err := getCLIFromState(ctx)
if err != nil {
return err
}
return cli.Claim(ctx)
},
Flags: []cli.Flag{&flags.PasswordFlag},
}
receiveCommand = cli.Command{
Name: "receive",
Usage: "Shows both onchain and offchain addresses",
Action: func(ctx *cli.Context) error {
cli, err := getCLIFromState(ctx)
if err != nil {
return err
}
return cli.Receive(ctx)
},
}
redeemCommand = cli.Command{
Name: "redeem",
Usage: "Redeem your offchain funds, either collaboratively or unilaterally",
Flags: []cli.Flag{&flags.AddressFlag, &flags.AmountToRedeemFlag, &flags.ForceFlag, &flags.PasswordFlag, &flags.EnableExpiryCoinselectFlag},
Action: func(ctx *cli.Context) error {
cli, err := getCLIFromState(ctx)
if err != nil {
return err
}
return cli.Redeem(ctx)
},
}
version = "alpha"
arkSdkClient arksdk.ArkClient
)
var Version string
func main() {
app := cli.NewApp()
app.Version = version
app.Name = "Ark CLI"
app.Version = Version
app.Usage = "Ark wallet command line interface"
app.Usage = "ark wallet command line interface"
app.Commands = append(
app.Commands,
&balanceCommand,
&initCommand,
&configCommand,
&dumpCommand,
&initCommand,
&receiveCommand,
&redeemCommand,
&claimCmd,
&sendCommand,
&claimCommand,
&balanceCommand,
&redeemCommand,
)
app.Flags = []cli.Flag{
flags.DatadirFlag,
datadirFlag,
networkFlag,
}
app.Before = func(ctx *cli.Context) error {
datadir := cleanAndExpandPath(ctx.String("datadir"))
if err := ctx.Set("datadir", datadir); err != nil {
return err
sdk, err := getArkSdkClient(ctx)
if err != nil {
return fmt.Errorf("error initializing ark sdk client: %v", err)
}
arkSdkClient = sdk
if _, err := os.Stat(datadir); os.IsNotExist(err) {
return os.Mkdir(datadir, os.ModeDir|0755)
}
return nil
}
@@ -179,54 +64,452 @@ func main() {
}
}
func getCLIFromState(ctx *cli.Context) (interfaces.CLI, error) {
state, err := utils.GetState(ctx)
var (
datadirFlag = &cli.StringFlag{
Name: "datadir",
Usage: "Specify the data directory",
Required: false,
Value: common.AppDataDir("ark-cli", false),
EnvVars: []string{DatadirEnvVar},
}
networkFlag = &cli.StringFlag{
Name: "network",
Usage: "network to use liquid, testnet, regtest, signet for bitcoin, or liquid, liquidtestnet, liquidregtest for liquid)",
Value: "liquid",
}
explorerFlag = &cli.StringFlag{
Name: "explorer",
Usage: "the url of the explorer to use",
}
passwordFlag = &cli.StringFlag{
Name: "password",
Usage: "password to unlock the wallet",
}
expiryDetailsFlag = &cli.BoolFlag{
Name: "compute-expiry-details",
Usage: "compute client-side VTXOs expiry time",
}
privateKeyFlag = &cli.StringFlag{
Name: "prvkey",
Usage: "optional private key to encrypt",
}
urlFlag = &cli.StringFlag{
Name: "asp-url",
Usage: "the url of the ASP to connect to",
Required: true,
}
receiversFlag = &cli.StringFlag{
Name: "receivers",
Usage: "JSON encoded receivers of the send transaction",
}
toFlag = &cli.StringFlag{
Name: "to",
Usage: "recipient address",
}
amountFlag = &cli.Uint64Flag{
Name: "amount",
Usage: "amount to send in sats",
}
enableExpiryCoinselectFlag = &cli.BoolFlag{
Name: "enable-expiry-coinselect",
Usage: "select VTXOs about to expire first",
}
addressFlag = &cli.StringFlag{
Name: "address",
Usage: "main chain address receiving the redeemed VTXO",
}
amountToRedeemFlag = &cli.Uint64Flag{
Name: "amount",
Usage: "amount to redeem",
}
forceFlag = &cli.BoolFlag{
Name: "force",
Usage: "force redemption without collaboration",
}
)
var (
initCommand = cli.Command{
Name: "init",
Usage: "Initialize Ark wallet with encryption password, connect to ASP",
Action: func(ctx *cli.Context) error {
return initArkSdk(ctx)
},
Flags: []cli.Flag{networkFlag, passwordFlag, privateKeyFlag, urlFlag, explorerFlag},
}
configCommand = cli.Command{
Name: "config",
Usage: "Shows Ark wallet configuration",
Action: func(ctx *cli.Context) error {
return config(ctx)
},
}
dumpCommand = cli.Command{
Name: "dump-privkey",
Usage: "Dumps private key of the Ark wallet",
Action: func(ctx *cli.Context) error {
return dumpPrivKey(ctx)
},
Flags: []cli.Flag{passwordFlag},
}
receiveCommand = cli.Command{
Name: "receive",
Usage: "Shows boarding and offchain addresses",
Action: func(ctx *cli.Context) error {
return receive(ctx)
},
}
claimCmd = cli.Command{
Name: "claim",
Usage: "Claim onboarding funds or pending payments",
Action: func(ctx *cli.Context) error {
return claim(ctx)
},
Flags: []cli.Flag{passwordFlag},
}
balanceCommand = cli.Command{
Name: "balance",
Usage: "Shows onchain and offchain Ark wallet balance",
Action: func(ctx *cli.Context) error {
return balance(ctx)
},
Flags: []cli.Flag{expiryDetailsFlag},
}
sendCommand = cli.Command{
Name: "send",
Usage: "Send funds onchain, offchain, or asynchronously",
Action: func(ctx *cli.Context) error {
return send(ctx)
},
Flags: []cli.Flag{receiversFlag, toFlag, amountFlag, enableExpiryCoinselectFlag, passwordFlag},
}
redeemCommand = cli.Command{
Name: "redeem",
Usage: "Redeem offchain funds, collaboratively or unilaterally",
Flags: []cli.Flag{addressFlag, amountToRedeemFlag, forceFlag, passwordFlag},
Action: func(ctx *cli.Context) error {
return redeem(ctx)
},
}
)
func initArkSdk(ctx *cli.Context) error {
password, err := readPassword(ctx)
if err != nil {
return err
}
return arkSdkClient.Init(
ctx.Context, arksdk.InitArgs{
ClientType: arksdk.GrpcClient,
WalletType: arksdk.SingleKeyWallet,
AspUrl: ctx.String(urlFlag.Name),
Seed: ctx.String(privateKeyFlag.Name),
Password: string(password),
ExplorerURL: ctx.String(explorerFlag.Name),
},
)
}
func config(ctx *cli.Context) error {
cfgStore, err := getConfigStore(ctx.String(datadirFlag.Name))
if err != nil {
return err
}
cfgData, err := cfgStore.GetData(ctx.Context)
if err != nil {
return err
}
cfg := map[string]interface{}{
"asp_url": cfgData.AspUrl,
"asp_pubkey": hex.EncodeToString(cfgData.AspPubkey.SerializeCompressed()),
"wallet_type": cfgData.WalletType,
"client_tyep": cfgData.ClientType,
"network": cfgData.Network.Name,
"round_lifetime": cfgData.RoundLifetime,
"unilateral_exit_delay": cfgData.UnilateralExitDelay,
"dust": cfgData.Dust,
"boarding_descriptor_template": cfgData.BoardingDescriptorTemplate,
"explorer_url": cfgData.ExplorerURL,
}
return printJSON(cfg)
}
func dumpPrivKey(ctx *cli.Context) error {
password, err := readPassword(ctx)
if err != nil {
return err
}
if err := arkSdkClient.Unlock(ctx.Context, string(password)); err != nil {
return err
}
privateKey, err := arkSdkClient.Dump(ctx.Context)
if err != nil {
return err
}
return printJSON(map[string]interface{}{
"private_key": privateKey,
})
}
func receive(ctx *cli.Context) error {
offchainAddr, boardingAddr, err := arkSdkClient.Receive(ctx.Context)
if err != nil {
return err
}
return printJSON(map[string]interface{}{
"boarding_address": boardingAddr,
"offchain_address": offchainAddr,
})
}
func claim(ctx *cli.Context) error {
password, err := readPassword(ctx)
if err != nil {
return err
}
if err := arkSdkClient.Unlock(ctx.Context, string(password)); err != nil {
return err
}
txID, err := arkSdkClient.Claim(ctx.Context)
if err != nil {
return err
}
return printJSON(map[string]interface{}{
"txid": txID,
})
}
func send(ctx *cli.Context) error {
receiversJSON := ctx.String(receiversFlag.Name)
to := ctx.String(toFlag.Name)
amount := ctx.Uint64(amountFlag.Name)
if receiversJSON == "" && to == "" && amount == 0 {
return fmt.Errorf("missing destination, use --to and --amount or --receivers")
}
configStore, err := getConfigStore(ctx.String(datadirFlag.Name))
if err != nil {
return err
}
cfgData, err := configStore.GetData(ctx.Context)
if err != nil {
return err
}
net := getNetwork(ctx, cfgData)
isBitcoin := isBtcChain(net)
var receivers []arksdk.Receiver
if receiversJSON != "" {
receivers, err = parseReceivers(receiversJSON, isBitcoin)
if err != nil {
return err
}
} else {
if isBitcoin {
receivers = []arksdk.Receiver{arksdk.NewBitcoinReceiver(to, amount)}
} else {
receivers = []arksdk.Receiver{arksdk.NewLiquidReceiver(to, amount)}
}
}
password, err := readPassword(ctx)
if err != nil {
return err
}
if err := arkSdkClient.Unlock(ctx.Context, string(password)); err != nil {
return err
}
if isBitcoin {
return sendCovenantLess(ctx, receivers)
}
return sendCovenant(ctx.Context, receivers)
}
func balance(ctx *cli.Context) error {
computeExpiration := ctx.Bool(expiryDetailsFlag.Name)
bal, err := arkSdkClient.Balance(ctx.Context, computeExpiration)
if err != nil {
return err
}
return printJSON(bal)
}
func redeem(ctx *cli.Context) error {
password, err := readPassword(ctx)
if err != nil {
return err
}
if err := arkSdkClient.Unlock(ctx.Context, string(password)); err != nil {
return err
}
force := ctx.Bool(forceFlag.Name)
address := ctx.String(addressFlag.Name)
amount := ctx.Uint64(amountToRedeemFlag.Name)
computeExpiration := ctx.Bool(expiryDetailsFlag.Name)
if force {
err := arkSdkClient.UnilateralRedeem(ctx.Context)
if err != nil {
return err
}
return nil
}
txID, err := arkSdkClient.CollaborativeRedeem(
ctx.Context, address, amount, computeExpiration,
)
if err != nil {
return err
}
return printJSON(map[string]interface{}{
"txid": txID,
})
}
func getArkSdkClient(ctx *cli.Context) (arksdk.ArkClient, error) {
cfgStore, err := getConfigStore(ctx.String(datadirFlag.Name))
if err != nil {
return nil, err
}
cfgData, err := cfgStore.GetData(ctx.Context)
if err != nil {
return nil, err
}
net := getNetwork(ctx, cfgData)
networkName := state[utils.NETWORK]
return getCLI(networkName)
if isBtcChain(net) {
return loadOrCreateClient(
arksdk.LoadCovenantlessClient, arksdk.NewCovenantlessClient, cfgStore,
)
}
return loadOrCreateClient(
arksdk.LoadCovenantClient, arksdk.NewCovenantClient, cfgStore,
)
}
func getCLIFromFlags(ctx *cli.Context) (interfaces.CLI, error) {
networkName := strings.ToLower(ctx.String("network"))
return getCLI(networkName)
func loadOrCreateClient(
loadFunc, newFunc func(store.ConfigStore) (arksdk.ArkClient, error),
store store.ConfigStore,
) (arksdk.ArkClient, error) {
client, err := loadFunc(store)
if err != nil {
if errors.Is(err, arksdk.ErrNotInitialized) {
return newFunc(store)
}
return nil, err
}
return client, err
}
func getCLI(networkName string) (interfaces.CLI, error) {
switch networkName {
case common.Liquid.Name, common.LiquidTestNet.Name, common.LiquidRegTest.Name:
return covenant.New(), nil
case common.Bitcoin.Name, common.BitcoinTestNet.Name, common.BitcoinRegTest.Name, common.BitcoinSigNet.Name:
return covenantless.New(), nil
default:
return nil, fmt.Errorf("unknown network (%s)", networkName)
}
func getConfigStore(dataDir string) (store.ConfigStore, error) {
return filestore.NewConfigStore(dataDir)
}
// cleanAndExpandPath expands environment variables and leading ~ in the
// passed path, cleans the result, and returns it.
// This function is taken from https://github.com/btcsuite/btcd
func cleanAndExpandPath(path string) string {
if path == "" {
return ""
func getNetwork(ctx *cli.Context, configData *store.StoreData) string {
if configData == nil {
return strings.ToLower(ctx.String(networkFlag.Name))
}
return configData.Network.Name
}
// Expand initial ~ to OS specific home directory.
if strings.HasPrefix(path, "~") {
var homeDir string
u, err := user.Current()
if err == nil {
homeDir = u.HomeDir
func isBtcChain(network string) bool {
return network == common.Bitcoin.Name ||
network == common.BitcoinTestNet.Name ||
network == common.BitcoinSigNet.Name ||
network == common.BitcoinRegTest.Name
}
func parseReceivers(receveirsJSON string, isBitcoin bool) ([]arksdk.Receiver, error) {
list := make([]map[string]interface{}, 0)
if err := json.Unmarshal([]byte(receveirsJSON), &list); err != nil {
return nil, err
}
receivers := make([]arksdk.Receiver, 0, len(list))
if isBitcoin {
for _, v := range list {
receivers = append(receivers, arksdk.NewBitcoinReceiver(
v["to"].(string), uint64(v["amount"].(float64)),
))
}
return receivers, nil
}
for _, v := range list {
receivers = append(receivers, arksdk.NewLiquidReceiver(
v["to"].(string), uint64(v["amount"].(float64)),
))
}
return receivers, nil
}
func sendCovenantLess(ctx *cli.Context, receivers []arksdk.Receiver) error {
computeExpiration := ctx.Bool(enableExpiryCoinselectFlag.Name)
txID, err := arkSdkClient.SendAsync(
ctx.Context, computeExpiration, receivers,
)
if err != nil {
return err
}
return printJSON(map[string]interface{}{"txid": txID})
}
func sendCovenant(ctx context.Context, receivers []arksdk.Receiver) error {
var onchainReceivers, offchainReceivers []arksdk.Receiver
for _, receiver := range receivers {
if receiver.IsOnchain() {
onchainReceivers = append(onchainReceivers, receiver)
} else {
homeDir = os.Getenv("HOME")
offchainReceivers = append(offchainReceivers, receiver)
}
}
path = strings.Replace(path, "~", homeDir, 1)
if len(onchainReceivers) > 0 {
txID, err := arkSdkClient.SendOnChain(ctx, onchainReceivers)
if err != nil {
return err
}
return printJSON(map[string]interface{}{"txid": txID})
}
// NOTE: The os.ExpandEnv doesn't work with Windows-style %VARIABLE%,
// but the variables can still be expanded via POSIX-style $VARIABLE.
return filepath.Clean(os.ExpandEnv(path))
if len(offchainReceivers) > 0 {
txID, err := arkSdkClient.SendOffChain(ctx, false, offchainReceivers)
if err != nil {
return err
}
return printJSON(map[string]interface{}{"txid": txID})
}
return nil
}
func readPassword(ctx *cli.Context) ([]byte, error) {
password := []byte(ctx.String("password"))
if len(password) == 0 {
fmt.Print("unlock your wallet with password: ")
var err error
password, err = term.ReadPassword(syscall.Stdin)
fmt.Println()
if err != nil {
return nil, err
}
}
return password, nil
}
func printJSON(resp interface{}) error {
jsonBytes, err := json.MarshalIndent(resp, "", "\t")
if err != nil {
return err
}
fmt.Println(string(jsonBytes))
return nil
}

View File

@@ -1,107 +0,0 @@
package utils
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
"runtime/debug"
"golang.org/x/crypto/scrypt"
)
type cypher struct{}
func NewAES128Cypher() *cypher {
return &cypher{}
}
func (c *cypher) Encrypt(privateKey, password []byte) ([]byte, error) {
// Due to https://github.com/golang/go/issues/7168.
// This call makes sure that memory is freed in case the GC doesn't do that
// right after the encryption/decryption.
defer debug.FreeOSMemory()
if len(privateKey) == 0 {
return nil, fmt.Errorf("missing plaintext private key")
}
if len(password) == 0 {
return nil, fmt.Errorf("missing encryption password")
}
key, salt, err := deriveKey(password, nil)
if err != nil {
return nil, err
}
blockCipher, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(blockCipher)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = rand.Read(nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, privateKey, nil)
ciphertext = append(ciphertext, salt...)
return ciphertext, nil
}
func (c *cypher) decrypt(encrypted, password []byte) ([]byte, error) {
defer debug.FreeOSMemory()
if len(encrypted) == 0 {
return nil, fmt.Errorf("missing encrypted mnemonic")
}
if len(password) == 0 {
return nil, fmt.Errorf("missing decryption password")
}
salt := encrypted[len(encrypted)-32:]
data := encrypted[:len(encrypted)-32]
key, _, err := deriveKey(password, salt)
if err != nil {
return nil, err
}
blockCipher, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(blockCipher)
if err != nil {
return nil, err
}
nonce, text := data[:gcm.NonceSize()], data[gcm.NonceSize():]
// #nosec G407
plaintext, err := gcm.Open(nil, nonce, text, nil)
if err != nil {
return nil, fmt.Errorf("invalid password")
}
return plaintext, nil
}
// deriveKey derives a 32 byte array key from a custom passhprase
func deriveKey(password, salt []byte) ([]byte, []byte, error) {
if salt == nil {
salt = make([]byte, 32)
if _, err := rand.Read(salt); err != nil {
return nil, nil, err
}
}
// 2^20 = 1048576 recommended length for key-stretching
// check the doc for other recommended values:
// https://godoc.org/golang.org/x/crypto/scrypt
key, err := scrypt.Key(password, salt, 1048576, 8, 1, 32)
if err != nil {
return nil, nil, err
}
return key, salt, nil
}

View File

@@ -1,358 +0,0 @@
package utils
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/btcsuite/btcd/btcutil/psbt"
"github.com/btcsuite/btcd/wire"
"github.com/urfave/cli/v2"
"github.com/vulpemventures/go-elements/psetv2"
"github.com/vulpemventures/go-elements/transaction"
)
type ExplorerUtxo struct {
Txid string `json:"txid"`
Vout uint32 `json:"vout"`
Amount uint64 `json:"value"`
Asset string `json:"asset,omitempty"` // optional
Status struct {
Confirmed bool `json:"confirmed"`
Blocktime int64 `json:"block_time"`
} `json:"status"`
}
type Explorer interface {
GetTxHex(txid string) (string, error)
Broadcast(txHex string) (string, error)
GetUtxos(addr string) ([]ExplorerUtxo, error)
GetBalance(addr, asset string) (uint64, error)
GetDelayedBalance(
addr string, unilateralExitDelay int64,
) (uint64, map[int64]uint64, error)
GetTxBlocktime(txid string) (confirmed bool, blocktime int64, err error)
GetFeeRate() (float64, error)
}
type explorer struct {
cache map[string]string
baseUrl string
}
func NewExplorer(ctx *cli.Context) Explorer {
baseUrl, err := getBaseURL(ctx)
if err != nil {
panic(err)
}
return &explorer{
cache: make(map[string]string),
baseUrl: baseUrl,
}
}
func (e *explorer) GetFeeRate() (float64, error) {
endpoint, err := url.JoinPath(e.baseUrl, "fee-estimates")
if err != nil {
return 0, err
}
resp, err := http.Get(endpoint)
if err != nil {
return 0, err
}
defer resp.Body.Close()
var response map[string]float64
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return 0, err
}
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("error getting fee rate: %s", resp.Status)
}
if len(response) == 0 {
fmt.Println("empty fee-estimates response, default to 2 sat/vbyte")
return 2, nil
}
return response["1"], nil
}
func (e *explorer) GetTxHex(txid string) (string, error) {
if hex, ok := e.cache[txid]; ok {
return hex, nil
}
txHex, err := e.getTxHex(txid)
if err != nil {
return "", err
}
e.cache[txid] = txHex
return txHex, nil
}
func (e *explorer) Broadcast(txStr string) (string, error) {
clone := strings.Clone(txStr)
txStr, txid, err := parseLiquidTx(txStr)
if err != nil {
txStr, txid, err = parseBitcoinTx(clone)
if err != nil {
fmt.Println("error parsing tx hex")
return "", err
}
}
e.cache[txid] = txStr
txid, err = e.broadcast(txStr)
if err != nil {
if strings.Contains(
strings.ToLower(err.Error()), "transaction already in block chain",
) {
return txid, nil
}
return "", err
}
return txid, nil
}
func (e *explorer) GetUtxos(addr string) ([]ExplorerUtxo, error) {
endpoint, err := url.JoinPath(e.baseUrl, "address", addr, "utxo")
if err != nil {
return nil, err
}
resp, err := http.Get(endpoint)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf(string(body))
}
payload := []ExplorerUtxo{}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, err
}
return payload, nil
}
func (e *explorer) GetBalance(addr, asset string) (uint64, error) {
payload, err := e.GetUtxos(addr)
if err != nil {
return 0, err
}
balance := uint64(0)
for _, p := range payload {
if len(asset) > 0 {
if p.Asset != asset {
continue
}
}
balance += p.Amount
}
return balance, nil
}
func (e *explorer) GetDelayedBalance(
addr string, unilateralExitDelay int64,
) (spendableBalance uint64, lockedBalance map[int64]uint64, err error) {
utxos, err := e.GetUtxos(addr)
if err != nil {
return
}
lockedBalance = make(map[int64]uint64, 0)
now := time.Now()
for _, utxo := range utxos {
blocktime := now
if utxo.Status.Confirmed {
blocktime = time.Unix(utxo.Status.Blocktime, 0)
}
delay := time.Duration(unilateralExitDelay) * time.Second
availableAt := blocktime.Add(delay)
if availableAt.After(now) {
if _, ok := lockedBalance[availableAt.Unix()]; !ok {
lockedBalance[availableAt.Unix()] = 0
}
lockedBalance[availableAt.Unix()] += utxo.Amount
} else {
spendableBalance += utxo.Amount
}
}
return
}
func (e *explorer) GetTxBlocktime(txid string) (confirmed bool, blocktime int64, err error) {
endpoint, err := url.JoinPath(e.baseUrl, "tx", txid)
if err != nil {
return false, 0, err
}
resp, err := http.Get(endpoint)
if err != nil {
return false, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return false, 0, err
}
if resp.StatusCode != http.StatusOK {
return false, 0, fmt.Errorf(string(body))
}
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 (e *explorer) getTxHex(txid string) (string, error) {
endpoint, err := url.JoinPath(e.baseUrl, "tx", txid, "hex")
if err != nil {
return "", err
}
resp, err := http.Get(endpoint)
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
}
func (e *explorer) broadcast(txHex string) (string, error) {
body := bytes.NewBuffer([]byte(txHex))
endpoint, err := url.JoinPath(e.baseUrl, "tx")
if err != nil {
return "", err
}
resp, err := http.Post(endpoint, "text/plain", body)
if err != nil {
return "", err
}
defer resp.Body.Close()
bodyResponse, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf(string(bodyResponse))
}
return string(bodyResponse), nil
}
func parseLiquidTx(txStr string) (string, string, error) {
tx, err := transaction.NewTxFromHex(txStr)
if err != nil {
pset, err := psetv2.NewPsetFromBase64(txStr)
if err != nil {
return "", "", err
}
tx, err = psetv2.Extract(pset)
if err != nil {
return "", "", err
}
txhex, err := tx.ToHex()
if err != nil {
return "", "", err
}
txid := tx.TxHash().String()
return txhex, txid, nil
}
txhex, err := tx.ToHex()
if err != nil {
return "", "", err
}
txid := tx.TxHash().String()
return txhex, txid, nil
}
func parseBitcoinTx(txStr string) (string, string, error) {
var tx wire.MsgTx
if err := tx.Deserialize(hex.NewDecoder(strings.NewReader(txStr))); err != nil {
ptx, err := psbt.NewFromRawBytes(strings.NewReader(txStr), true)
if err != nil {
return "", "", err
}
txFromPartial, err := psbt.Extract(ptx)
if err != nil {
return "", "", err
}
tx = *txFromPartial
}
var txBuf bytes.Buffer
if err := tx.Serialize(&txBuf); err != nil {
return "", "", err
}
txhex := hex.EncodeToString(txBuf.Bytes())
txid := tx.TxHash().String()
return txhex, txid, nil
}

View File

@@ -1,16 +0,0 @@
package utils
import (
"encoding/json"
"fmt"
)
func PrintJSON(resp interface{}) error {
jsonBytes, err := json.MarshalIndent(resp, "", "\t")
if err != nil {
return err
}
fmt.Println(string(jsonBytes))
return nil
}

View File

@@ -1,65 +0,0 @@
package utils
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"syscall"
"github.com/urfave/cli/v2"
"golang.org/x/term"
)
func ReadPassword(ctx *cli.Context, verify bool) ([]byte, error) {
password := []byte(ctx.String("password"))
if len(password) == 0 {
fmt.Print("unlock your wallet with password: ")
var err error
password, err = term.ReadPassword(int(syscall.Stdin))
fmt.Println() // new line
if err != nil {
return nil, err
}
}
if verify {
if err := verifyPassword(ctx, password); err != nil {
return nil, err
}
}
return password, nil
}
func HashPassword(password []byte) []byte {
hash := sha256.Sum256(password)
return hash[:]
}
func verifyPassword(ctx *cli.Context, password []byte) error {
state, err := GetState(ctx)
if err != nil {
return err
}
passwordHashString := state[PASSWORD_HASH]
if len(passwordHashString) <= 0 {
return fmt.Errorf("missing password hash")
}
passwordHash, err := hex.DecodeString(passwordHashString)
if err != nil {
return err
}
currentPassHash := HashPassword(password)
if !bytes.Equal(passwordHash, currentPassHash) {
return fmt.Errorf("invalid password")
}
return nil
}

View File

@@ -1,304 +0,0 @@
package utils
import (
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/ark-network/ark/common"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/urfave/cli/v2"
)
const (
ASP_URL = "asp_url"
ASP_PUBKEY = "asp_public_key"
ROUND_LIFETIME = "round_lifetime"
UNILATERAL_EXIT_DELAY = "unilateral_exit_delay"
BOARDING_TEMPLATE = "boarding_template"
ENCRYPTED_PRVKEY = "encrypted_private_key"
PASSWORD_HASH = "password_hash"
PUBKEY = "public_key"
NETWORK = "network"
EXPLORER = "explorer"
DUST = "dust"
defaultNetwork = "liquid"
state_file = "state.json"
)
var initialState = map[string]string{
ASP_URL: "",
ASP_PUBKEY: "",
ROUND_LIFETIME: "",
UNILATERAL_EXIT_DELAY: "",
ENCRYPTED_PRVKEY: "",
PASSWORD_HASH: "",
PUBKEY: "",
DUST: "546",
NETWORK: defaultNetwork,
}
func GetNetwork(ctx *cli.Context) (*common.Network, error) {
state, err := GetState(ctx)
if err != nil {
return nil, err
}
net, ok := state[NETWORK]
if !ok {
return nil, fmt.Errorf("network not found in state")
}
return networkFromString(net), nil
}
func GetRoundLifetime(ctx *cli.Context) (int64, error) {
state, err := GetState(ctx)
if err != nil {
return -1, err
}
lifetime := state[ROUND_LIFETIME]
if len(lifetime) <= 0 {
return -1, fmt.Errorf("missing round lifetime")
}
roundLifetime, err := strconv.Atoi(lifetime)
if err != nil {
return -1, err
}
return int64(roundLifetime), nil
}
func GetUnilateralExitDelay(ctx *cli.Context) (int64, error) {
state, err := GetState(ctx)
if err != nil {
return -1, err
}
delay := state[UNILATERAL_EXIT_DELAY]
if len(delay) <= 0 {
return -1, fmt.Errorf("missing unilateral exit delay")
}
redeemDelay, err := strconv.Atoi(delay)
if err != nil {
return -1, err
}
return int64(redeemDelay), nil
}
func GetBoardingDescriptor(ctx *cli.Context) (string, error) {
state, err := GetState(ctx)
if err != nil {
return "", err
}
pubkey, err := GetWalletPublicKey(ctx)
if err != nil {
return "", err
}
template := state[BOARDING_TEMPLATE]
if len(template) <= 0 {
return "", fmt.Errorf("missing boarding descriptor template")
}
pubkeyhex := hex.EncodeToString(schnorr.SerializePubKey(pubkey))
return strings.ReplaceAll(template, "USER", pubkeyhex), nil
}
func GetWalletPublicKey(ctx *cli.Context) (*secp256k1.PublicKey, error) {
state, err := GetState(ctx)
if err != nil {
return nil, err
}
publicKeyString := state[PUBKEY]
if len(publicKeyString) <= 0 {
return nil, fmt.Errorf("missing public key")
}
publicKeyBytes, err := hex.DecodeString(publicKeyString)
if err != nil {
return nil, err
}
return secp256k1.ParsePubKey(publicKeyBytes)
}
func GetAspPublicKey(ctx *cli.Context) (*secp256k1.PublicKey, error) {
state, err := GetState(ctx)
if err != nil {
return nil, err
}
arkPubKey := state[ASP_PUBKEY]
if len(arkPubKey) <= 0 {
return nil, fmt.Errorf("missing asp public key")
}
pubKeyBytes, err := hex.DecodeString(arkPubKey)
if err != nil {
return nil, err
}
return secp256k1.ParsePubKey(pubKeyBytes)
}
func GetDust(ctx *cli.Context) (uint64, error) {
state, err := GetState(ctx)
if err != nil {
return 0, err
}
dust := state[DUST]
if len(dust) <= 0 {
return 0, fmt.Errorf("missing dust")
}
dustAmount, err := strconv.Atoi(dust)
if err != nil {
return 0, err
}
return uint64(dustAmount), nil
}
func GetState(ctx *cli.Context) (map[string]string, error) {
datadir := ctx.String("datadir")
stateFilePath := filepath.Join(datadir, state_file)
file, err := os.ReadFile(stateFilePath)
if err != nil {
if !os.IsNotExist(err) {
return nil, err
}
if err := setInitialState(stateFilePath); err != nil {
return nil, err
}
return initialState, nil
}
data := map[string]string{}
if err := json.Unmarshal(file, &data); err != nil {
return nil, err
}
return data, nil
}
func PrivateKeyFromPassword(ctx *cli.Context) (*secp256k1.PrivateKey, error) {
state, err := GetState(ctx)
if err != nil {
return nil, err
}
encryptedPrivateKeyString := state[ENCRYPTED_PRVKEY]
if len(encryptedPrivateKeyString) <= 0 {
return nil, fmt.Errorf("missing encrypted private key")
}
encryptedPrivateKey, err := hex.DecodeString(encryptedPrivateKeyString)
if err != nil {
return nil, fmt.Errorf("invalid encrypted private key: %s", err)
}
password, err := ReadPassword(ctx, true)
if err != nil {
return nil, err
}
fmt.Println("wallet unlocked")
cypher := NewAES128Cypher()
privateKeyBytes, err := cypher.decrypt(encryptedPrivateKey, password)
if err != nil {
return nil, err
}
privateKey := secp256k1.PrivKeyFromBytes(privateKeyBytes)
return privateKey, nil
}
func SetState(ctx *cli.Context, data map[string]string) error {
currentData, err := GetState(ctx)
if err != nil {
return err
}
mergedData := merge(currentData, data)
jsonString, err := json.Marshal(mergedData)
if err != nil {
return err
}
datadir := ctx.String("datadir")
statePath := filepath.Join(datadir, state_file)
err = os.WriteFile(statePath, jsonString, 0755)
if err != nil {
return fmt.Errorf("writing to file: %w", err)
}
return nil
}
func networkFromString(net string) *common.Network {
switch net {
case common.Liquid.Name:
return &common.Liquid
case common.LiquidTestNet.Name:
return &common.LiquidTestNet
case common.LiquidRegTest.Name:
return &common.LiquidRegTest
case common.Bitcoin.Name:
return &common.Bitcoin
case common.BitcoinTestNet.Name:
return &common.BitcoinTestNet
case common.BitcoinRegTest.Name:
return &common.BitcoinRegTest
case common.BitcoinSigNet.Name:
return &common.BitcoinSigNet
default:
panic(fmt.Sprintf("unknown network (%s)", net))
}
}
func setInitialState(stateFilePath string) error {
jsonString, err := json.Marshal(initialState)
if err != nil {
return err
}
return os.WriteFile(stateFilePath, jsonString, 0755)
}
func getBaseURL(ctx *cli.Context) (string, error) {
state, err := GetState(ctx)
if err != nil {
return "", err
}
baseURL := state[EXPLORER]
if len(baseURL) <= 0 {
return "", fmt.Errorf("missing explorer base url")
}
return baseURL, nil
}
func merge(maps ...map[string]string) map[string]string {
merge := make(map[string]string, 0)
for _, m := range maps {
for k, v := range m {
merge[k] = v
}
}
return merge
}

View File

@@ -1,36 +0,0 @@
package utils
import (
"time"
"github.com/ark-network/ark/common"
)
type Utxo struct {
Txid string
Vout uint32
Amount uint64
Asset string // optional
Delay uint
SpendableAt time.Time
}
func (u *Utxo) Sequence() (uint32, error) {
return common.BIP68EncodeAsNumber(u.Delay)
}
func NewUtxo(explorerUtxo ExplorerUtxo, delay uint) Utxo {
utxoTime := explorerUtxo.Status.Blocktime
if utxoTime == 0 {
utxoTime = time.Now().Unix()
}
return Utxo{
Txid: explorerUtxo.Txid,
Vout: explorerUtxo.Vout,
Amount: explorerUtxo.Amount,
Asset: explorerUtxo.Asset,
Delay: delay,
SpendableAt: time.Unix(utxoTime, 0).Add(time.Duration(delay) * time.Second),
}
}

View File

@@ -28,11 +28,12 @@ type ArkClient interface {
Claim(ctx context.Context) (string, error)
ListVtxos(ctx context.Context) ([]client.Vtxo, []client.Vtxo, error)
GetTransactionHistory(ctx context.Context) ([]Transaction, error)
Dump(ctx context.Context) (seed string, err error)
}
type Receiver interface {
To() string
Amount() uint64
isOnchain() bool
IsOnchain() bool
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"time"
"github.com/ark-network/ark/common"
"github.com/ark-network/ark/pkg/client-sdk/client"
"github.com/ark-network/ark/pkg/client-sdk/explorer"
"github.com/ark-network/ark/pkg/client-sdk/internal/utils"
@@ -38,6 +39,18 @@ var (
ErrNotInitialized = fmt.Errorf("client not initialized")
)
var (
defaultNetworks = utils.SupportedType[string]{
common.Liquid.Name: "https://blockstream.info/liquid/api",
common.LiquidTestNet.Name: "https://blockstream.info/liquidtestnet/api",
common.LiquidRegTest.Name: "http://localhost:3001",
common.Bitcoin.Name: "https://blockstream.info/api",
common.BitcoinTestNet.Name: "https://blockstream.info/testnet/api",
common.BitcoinRegTest.Name: "http://localhost:3000",
common.BitcoinSigNet.Name: "https://mutinynet.com/api",
}
)
type arkClient struct {
*store.StoreData
wallet wallet.WalletService
@@ -74,7 +87,7 @@ func (a *arkClient) InitWithWallet(
return fmt.Errorf("failed to connect to asp: %s", err)
}
explorerSvc, err := getExplorer(supportedNetworks, info.Network)
explorerSvc, err := getExplorer(args.ExplorerURL, info.Network)
if err != nil {
return fmt.Errorf("failed to setup explorer: %s", err)
}
@@ -138,7 +151,7 @@ func (a *arkClient) Init(
return fmt.Errorf("failed to connect to asp: %s", err)
}
explorerSvc, err := getExplorer(supportedNetworks, info.Network)
explorerSvc, err := getExplorer(args.ExplorerURL, info.Network)
if err != nil {
return fmt.Errorf("failed to setup explorer: %s", err)
}
@@ -164,6 +177,7 @@ func (a *arkClient) Init(
UnilateralExitDelay: info.UnilateralExitDelay,
Dust: info.Dust,
BoardingDescriptorTemplate: info.BoardingDescriptorTemplate,
ExplorerURL: args.ExplorerURL,
}
walletSvc, err := getWallet(a.store, &storeData, supportedWallets)
if err != nil {
@@ -201,6 +215,10 @@ func (a *arkClient) IsLocked(ctx context.Context) bool {
return a.wallet.IsLocked()
}
func (a *arkClient) Dump(ctx context.Context) (string, error) {
return a.wallet.Dump(ctx)
}
func (a *arkClient) Receive(ctx context.Context) (string, string, error) {
offchainAddr, boardingAddr, err := a.wallet.NewAddress(ctx, false)
if err != nil {
@@ -254,15 +272,14 @@ func getClient(
return factory(aspUrl)
}
func getExplorer(
supportedNetworks utils.SupportedType[string], network string,
) (explorer.Explorer, error) {
url, ok := supportedNetworks[network]
if !ok {
func getExplorer(explorerURL, network string) (explorer.Explorer, error) {
if explorerURL == "" {
var ok bool
if explorerURL, ok = defaultNetworks[network]; !ok {
return nil, fmt.Errorf("invalid network")
}
return explorer.NewExplorer(url, utils.NetworkFromString(network)), nil
}
return explorer.NewExplorer(explorerURL, utils.NetworkFromString(network)), nil
}
func getWallet(

View File

@@ -45,7 +45,7 @@ func (r liquidReceiver) Amount() uint64 {
return r.amount
}
func (r liquidReceiver) isOnchain() bool {
func (r liquidReceiver) IsOnchain() bool {
_, err := address.ToOutputScript(r.to)
return err == nil
}
@@ -86,7 +86,7 @@ func LoadCovenantClient(storeSvc store.ConfigStore) (ArkClient, error) {
return nil, fmt.Errorf("failed to setup transport client: %s", err)
}
explorerSvc, err := getExplorer(supportedNetworks, data.Network.Name)
explorerSvc, err := getExplorer(data.ExplorerURL, data.Network.Name)
if err != nil {
return nil, fmt.Errorf("failed to setup explorer: %s", err)
}
@@ -126,7 +126,7 @@ func LoadCovenantClientWithWallet(
return nil, fmt.Errorf("failed to setup transport client: %s", err)
}
explorerSvc, err := getExplorer(supportedNetworks, data.Network.Name)
explorerSvc, err := getExplorer(data.ExplorerURL, data.Network.Name)
if err != nil {
return nil, fmt.Errorf("failed to setup explorer: %s", err)
}
@@ -287,7 +287,7 @@ func (a *covenantArkClient) SendOnChain(
ctx context.Context, receivers []Receiver,
) (string, error) {
for _, receiver := range receivers {
if !receiver.isOnchain() {
if !receiver.IsOnchain() {
return "", fmt.Errorf("invalid receiver address '%s': must be onchain", receiver.To())
}
}
@@ -300,7 +300,7 @@ func (a *covenantArkClient) SendOffChain(
withExpiryCoinselect bool, receivers []Receiver,
) (string, error) {
for _, receiver := range receivers {
if receiver.isOnchain() {
if receiver.IsOnchain() {
return "", fmt.Errorf("invalid receiver address '%s': must be offchain", receiver.To())
}
}
@@ -1062,7 +1062,7 @@ func (a *covenantArkClient) validateCongestionTree(
connectors := event.Connectors
if !utils.IsOnchainOnly(receivers) {
if !utils.IsLiquidOnchainOnly(receivers) {
if err := tree.ValidateCongestionTree(
event.Tree, poolTx, a.StoreData.AspPubkey, a.RoundLifetime,
); err != nil {
@@ -1092,7 +1092,7 @@ func (a *covenantArkClient) validateReceivers(
aspPubkey *secp256k1.PublicKey,
) error {
for _, receiver := range receivers {
isOnChain, onchainScript, userPubkey, err := utils.DecodeReceiverAddress(
isOnChain, onchainScript, userPubkey, err := utils.ParseLiquidAddress(
receiver.Address,
)
if err != nil {

View File

@@ -29,7 +29,6 @@ import (
"github.com/btcsuite/btcd/wire"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
log "github.com/sirupsen/logrus"
"github.com/vulpemventures/go-elements/address"
)
type bitcoinReceiver struct {
@@ -49,7 +48,7 @@ func (r bitcoinReceiver) Amount() uint64 {
return r.amount
}
func (r bitcoinReceiver) isOnchain() bool {
func (r bitcoinReceiver) IsOnchain() bool {
_, err := btcutil.DecodeAddress(r.to, nil)
return err == nil
}
@@ -90,7 +89,7 @@ func LoadCovenantlessClient(storeSvc store.ConfigStore) (ArkClient, error) {
return nil, fmt.Errorf("failed to setup transport client: %s", err)
}
explorerSvc, err := getExplorer(supportedNetworks, data.Network.Name)
explorerSvc, err := getExplorer(data.ExplorerURL, data.Network.Name)
if err != nil {
return nil, fmt.Errorf("failed to setup explorer: %s", err)
}
@@ -130,7 +129,7 @@ func LoadCovenantlessClientWithWallet(
return nil, fmt.Errorf("failed to setup transport client: %s", err)
}
explorerSvc, err := getExplorer(supportedNetworks, data.Network.Name)
explorerSvc, err := getExplorer(data.ExplorerURL, data.Network.Name)
if err != nil {
return nil, fmt.Errorf("failed to setup explorer: %s", err)
}
@@ -291,7 +290,7 @@ func (a *covenantlessArkClient) SendOnChain(
ctx context.Context, receivers []Receiver,
) (string, error) {
for _, receiver := range receivers {
if !receiver.isOnchain() {
if !receiver.IsOnchain() {
return "", fmt.Errorf("invalid receiver address '%s': must be onchain", receiver.To())
}
}
@@ -304,7 +303,7 @@ func (a *covenantlessArkClient) SendOffChain(
withExpiryCoinselect bool, receivers []Receiver,
) (string, error) {
for _, receiver := range receivers {
if receiver.isOnchain() {
if receiver.IsOnchain() {
return "", fmt.Errorf("invalid receiver address '%s': must be offchain", receiver.To())
}
}
@@ -388,24 +387,11 @@ func (a *covenantlessArkClient) CollaborativeRedeem(
return "", fmt.Errorf("wallet is locked")
}
if _, err := address.ToOutputScript(addr); err != nil {
netParams := utils.ToBitcoinNetwork(a.Network)
if _, err := btcutil.DecodeAddress(addr, &netParams); err != nil {
return "", fmt.Errorf("invalid onchain address")
}
addrNet, err := address.NetworkForAddress(addr)
if err != nil {
return "", fmt.Errorf("invalid onchain address: unknown network")
}
net := utils.ToElementsNetwork(a.Network)
if net.Name != addrNet.Name {
return "", fmt.Errorf("invalid onchain address: must be for %s network", net.Name)
}
if isConf, _ := address.IsConfidential(addr); isConf {
info, _ := address.FromConfidential(addr)
addr = info.Address
}
offchainAddrs, _, _, err := a.wallet.GetAddresses(ctx)
if err != nil {
return "", err
@@ -490,8 +476,9 @@ func (a *covenantlessArkClient) SendAsync(
return "", fmt.Errorf("missing receivers")
}
netParams := utils.ToBitcoinNetwork(a.Network)
for _, receiver := range receivers {
isOnchain, _, _, err := utils.DecodeReceiverAddress(receiver.To())
isOnchain, _, _, err := utils.ParseBitcoinAddress(receiver.To(), netParams)
if err != nil {
return "", err
}
@@ -1234,7 +1221,8 @@ func (a *covenantlessArkClient) validateCongestionTree(
return err
}
if !utils.IsOnchainOnly(receivers) {
netParams := utils.ToBitcoinNetwork(a.Network)
if !utils.IsBitcoinOnchainOnly(receivers, netParams) {
if err := bitcointree.ValidateCongestionTree(
event.Tree, poolTx, a.StoreData.AspPubkey, a.RoundLifetime,
); err != nil {
@@ -1263,9 +1251,10 @@ func (a *covenantlessArkClient) validateReceivers(
congestionTree tree.CongestionTree,
aspPubkey *secp256k1.PublicKey,
) error {
netParams := utils.ToBitcoinNetwork(a.Network)
for _, receiver := range receivers {
isOnChain, onchainScript, userPubkey, err := utils.DecodeReceiverAddress(
receiver.Address,
isOnChain, onchainScript, userPubkey, err := utils.ParseBitcoinAddress(
receiver.Address, netParams,
)
if err != nil {
return err

View File

@@ -12,7 +12,9 @@ import (
"github.com/ark-network/ark/common"
"github.com/ark-network/ark/pkg/client-sdk/client"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/txscript"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/vulpemventures/go-elements/address"
"github.com/vulpemventures/go-elements/network"
@@ -63,7 +65,7 @@ func CoinSelect(
return selected, change, nil
}
func DecodeReceiverAddress(addr string) (
func ParseLiquidAddress(addr string) (
bool, []byte, *secp256k1.PublicKey, error,
) {
outputScript, err := address.ToOutputScript(addr)
@@ -78,9 +80,43 @@ func DecodeReceiverAddress(addr string) (
return true, outputScript, nil, nil
}
func IsOnchainOnly(receivers []client.Output) bool {
func ParseBitcoinAddress(addr string, net chaincfg.Params) (
bool, []byte, *secp256k1.PublicKey, error,
) {
btcAddr, err := btcutil.DecodeAddress(addr, &net)
if err != nil {
_, userPubkey, _, err := common.DecodeAddress(addr)
if err != nil {
return false, nil, nil, err
}
return false, nil, userPubkey, nil
}
onchainScript, err := txscript.PayToAddrScript(btcAddr)
if err != nil {
return false, nil, nil, err
}
return true, onchainScript, nil, nil
}
func IsBitcoinOnchainOnly(receivers []client.Output, net chaincfg.Params) bool {
for _, receiver := range receivers {
isOnChain, _, _, err := DecodeReceiverAddress(receiver.Address)
isOnChain, _, _, err := ParseBitcoinAddress(receiver.Address, net)
if err != nil {
continue
}
if !isOnChain {
return false
}
}
return true
}
func IsLiquidOnchainOnly(receivers []client.Output) bool {
for _, receiver := range receivers {
isOnChain, _, _, err := ParseLiquidAddress(receiver.Address)
if err != nil {
continue
}

View File

@@ -30,6 +30,7 @@ type storeData struct {
UnilateralExitDelay string `json:"unilateral_exit_delay"`
Dust string `json:"dust"`
BoardingDescriptorTemplate string `json:"boarding_descriptor_template"`
ExplorerURL string `json:"explorer_url"`
}
func (d storeData) isEmpty() bool {
@@ -43,6 +44,7 @@ func (d storeData) decode() store.StoreData {
dust, _ := strconv.Atoi(d.Dust)
buf, _ := hex.DecodeString(d.AspPubkey)
aspPubkey, _ := secp256k1.ParsePubKey(buf)
explorerURL := d.ExplorerURL
return store.StoreData{
AspUrl: d.AspUrl,
AspPubkey: aspPubkey,
@@ -53,6 +55,7 @@ func (d storeData) decode() store.StoreData {
UnilateralExitDelay: int64(unilateralExitDelay),
Dust: uint64(dust),
BoardingDescriptorTemplate: d.BoardingDescriptorTemplate,
ExplorerURL: explorerURL,
}
}
@@ -67,6 +70,7 @@ func (d storeData) asMap() map[string]string {
"unilateral_exit_delay": d.UnilateralExitDelay,
"dust": d.Dust,
"boarding_descriptor_template": d.BoardingDescriptorTemplate,
"explorer_url": d.ExplorerURL,
}
}
@@ -112,6 +116,7 @@ func (s *Store) AddData(ctx context.Context, data store.StoreData) error {
UnilateralExitDelay: fmt.Sprintf("%d", data.UnilateralExitDelay),
Dust: fmt.Sprintf("%d", data.Dust),
BoardingDescriptorTemplate: data.BoardingDescriptorTemplate,
ExplorerURL: data.ExplorerURL,
}
if err := s.write(sd); err != nil {

View File

@@ -22,6 +22,7 @@ type StoreData struct {
UnilateralExitDelay int64
Dust uint64
BoardingDescriptorTemplate string
ExplorerURL string
}
type ConfigStore interface {

View File

@@ -4,7 +4,6 @@ import (
"fmt"
"time"
"github.com/ark-network/ark/common"
grpcclient "github.com/ark-network/ark/pkg/client-sdk/client/grpc"
restclient "github.com/ark-network/ark/pkg/client-sdk/client/rest"
"github.com/ark-network/ark/pkg/client-sdk/internal/utils"
@@ -19,15 +18,6 @@ var (
GrpcClient: grpcclient.NewClient,
RestClient: restclient.NewClient,
}
supportedNetworks = utils.SupportedType[string]{
common.Liquid.Name: "https://blockstream.info/liquid/api",
common.LiquidTestNet.Name: "https://blockstream.info/liquidtestnet/api",
common.LiquidRegTest.Name: "http://localhost:3001",
common.Bitcoin.Name: "https://blockstream.info/api",
common.BitcoinTestNet.Name: "https://blockstream.info/testnet/api",
common.BitcoinRegTest.Name: "http://localhost:3000",
common.BitcoinSigNet.Name: "https://mutinynet.com/api",
}
)
type InitArgs struct {
@@ -36,6 +26,7 @@ type InitArgs struct {
AspUrl string
Seed string
Password string
ExplorerURL string
}
func (a InitArgs) validate() error {
@@ -74,6 +65,7 @@ type InitWithWalletArgs struct {
AspUrl string
Seed string
Password string
ExplorerURL string
}
func (a InitWithWalletArgs) validate() error {

View File

@@ -116,3 +116,16 @@ func (w *singlekeyWallet) Unlock(
func (w *singlekeyWallet) IsLocked() bool {
return w.privateKey == nil
}
func (w *singlekeyWallet) Dump(ctx context.Context) (string, error) {
if w.walletData == nil {
return "", fmt.Errorf("wallet not initialized")
}
if w.IsLocked() {
return "", fmt.Errorf("wallet is locked")
}
return hex.EncodeToString(w.privateKey.Serialize()), nil
}

View File

@@ -30,4 +30,5 @@ type WalletService interface {
SignTransaction(
ctx context.Context, explorerSvc explorer.Explorer, tx string,
) (signedTx string, err error)
Dump(ctx context.Context) (seed string, err error)
}

View File

@@ -121,6 +121,8 @@ func TestUnilateralExit(t *testing.T) {
err = utils.GenerateBlock()
require.NoError(t, err)
time.Sleep(5 * time.Second)
balanceStr, err = runClarkCommand("balance")
require.NoError(t, err)
require.NoError(t, json.Unmarshal([]byte(balanceStr), &balance))