package main import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "sort" "syscall" "time" arkv1 "github.com/ark-network/ark/api-spec/protobuf/gen/ark/v1" "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/address" "github.com/vulpemventures/go-elements/elementsutil" "github.com/vulpemventures/go-elements/network" "github.com/vulpemventures/go-elements/payment" "github.com/vulpemventures/go-elements/psetv2" "github.com/vulpemventures/go-elements/taproot" "github.com/vulpemventures/go-elements/transaction" "golang.org/x/term" ) const ( DUST = 450 ) func hashPassword(password []byte) []byte { hash := sha256.Sum256(password) return hash[:] } func verifyPassword(password []byte) error { state, err := getState() if err != nil { return err } passwordHashString, ok := state["password_hash"].(string) if !ok { return fmt.Errorf("password hash not found") } 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 } func readPassword() ([]byte, error) { fmt.Print("unlock your wallet with password: ") passwordInput, err := term.ReadPassword(int(syscall.Stdin)) fmt.Println() // new line if err != nil { return nil, err } if err := verifyPassword(passwordInput); err != nil { return nil, err } return passwordInput, nil } func privateKeyFromPassword() (*secp256k1.PrivateKey, error) { state, err := getState() if err != nil { return nil, err } encryptedPrivateKeyString, ok := state["encrypted_private_key"].(string) if !ok { return nil, fmt.Errorf("encrypted private key not found") } encryptedPrivateKey, err := hex.DecodeString(encryptedPrivateKeyString) if err != nil { return nil, fmt.Errorf("invalid encrypted private key: %s", err) } password, err := readPassword() 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 getWalletPublicKey() (*secp256k1.PublicKey, error) { state, err := getState() if err != nil { return nil, err } publicKeyString, ok := state["public_key"].(string) if !ok { return nil, fmt.Errorf("public key not found") } publicKeyBytes, err := hex.DecodeString(publicKeyString) if err != nil { return nil, err } return secp256k1.ParsePubKey(publicKeyBytes) } func getServiceProviderPublicKey() (*secp256k1.PublicKey, error) { state, err := getState() if err != nil { return nil, err } arkPubKey, ok := state["ark_pubkey"].(string) if !ok { return nil, fmt.Errorf("ark public key not found") } pubKeyBytes, err := hex.DecodeString(arkPubKey) if err != nil { return nil, err } return secp256k1.ParsePubKey(pubKeyBytes) } func getLifetime() (int64, error) { state, err := getState() if err != nil { return 0, err } lifetime, ok := state["ark_lifetime"].(float64) if !ok { return 0, fmt.Errorf("lifetime not found") } return int64(lifetime), nil } func getExitDelay() (int64, error) { state, err := getState() if err != nil { return 0, err } exitDelay, ok := state["exit_delay"].(float64) if !ok { return 0, fmt.Errorf("exit delay not found") } return int64(exitDelay), nil } func coinSelect(vtxos []vtxo, amount uint64) ([]vtxo, uint64, error) { selected := make([]vtxo, 0) notSelected := make([]vtxo, 0) selectedAmount := uint64(0) // sort vtxos by expiration (older first) sort.SliceStable(vtxos, func(i, j int) bool { if vtxos[i].expireAt == nil || vtxos[j].expireAt == nil { return false } return vtxos[i].expireAt.Before(*vtxos[j].expireAt) }) for _, vtxo := range vtxos { if selectedAmount >= amount { notSelected = append(notSelected, vtxo) break } selected = append(selected, vtxo) selectedAmount += vtxo.amount } if selectedAmount < amount { return nil, 0, fmt.Errorf("insufficient balance: %d to cover %d", selectedAmount, amount) } change := selectedAmount - amount if change < DUST { if len(notSelected) > 0 { selected = append(selected, notSelected[0]) change += notSelected[0].amount } } return selected, change, nil } func getOffchainBalance( ctx *cli.Context, explorer Explorer, client arkv1.ArkServiceClient, addr string, withExpiration bool, ) (uint64, map[int64]uint64, error) { amountByExpiration := make(map[int64]uint64, 0) vtxos, err := getVtxos(ctx, explorer, client, addr, withExpiration) if err != nil { return 0, nil, err } var balance uint64 for _, vtxo := range vtxos { balance += vtxo.amount if withExpiration { expiration := vtxo.expireAt.Unix() if _, ok := amountByExpiration[expiration]; !ok { amountByExpiration[expiration] = 0 } amountByExpiration[expiration] += vtxo.amount } } return balance, amountByExpiration, nil } type utxo struct { Txid string `json:"txid"` Vout uint32 `json:"vout"` Amount uint64 `json:"value"` Asset string `json:"asset"` Status struct { Confirmed bool `json:"confirmed"` Blocktime int64 `json:"block_time"` } `json:"status"` } func getOnchainUtxos(addr string) ([]utxo, error) { _, net := getNetwork() baseUrl := explorerUrl[net.Name] resp, err := http.Get(fmt.Sprintf("%s/address/%s/utxo", baseUrl, addr)) 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 := []utxo{} if err := json.Unmarshal(body, &payload); err != nil { return nil, err } return payload, nil } func getOnchainBalance(addr string) (uint64, error) { payload, err := getOnchainUtxos(addr) if err != nil { return 0, err } _, net := getNetwork() balance := uint64(0) for _, p := range payload { if p.Asset != net.AssetID { continue } balance += p.Amount } return balance, nil } func getOnchainVtxosBalance() (availableBalance uint64, futureBalance map[int64]uint64, err error) { userPubKey, err := getWalletPublicKey() if err != nil { return } aspPublicKey, err := getServiceProviderPublicKey() if err != nil { return } exitDelay, err := getExitDelay() if err != nil { return } vtxoTapKey, _, err := computeVtxoTaprootScript(userPubKey, aspPublicKey, uint(exitDelay)) if err != nil { return } _, net := getNetwork() payment, err := payment.FromTweakedKey(vtxoTapKey, net, nil) if err != nil { return } addr, err := payment.TaprootAddress() if err != nil { return } utxos, err := getOnchainUtxos(addr) if err != nil { return } availableBalance = uint64(0) futureBalance = 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) } availableAt := blocktime.Add(time.Duration(exitDelay) * time.Second) if availableAt.After(now) { if _, ok := futureBalance[availableAt.Unix()]; !ok { futureBalance[availableAt.Unix()] = 0 } futureBalance[availableAt.Unix()] += utxo.Amount } else { availableBalance += utxo.Amount } } return } func getTxBlocktime(txid string) (confirmed bool, blocktime int64, err error) { _, net := getNetwork() baseUrl := explorerUrl[net.Name] resp, err := http.Get(fmt.Sprintf("%s/tx/%s", baseUrl, txid)) 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 broadcast(txHex string) (string, error) { _, net := getNetwork() body := bytes.NewBuffer([]byte(txHex)) baseUrl := explorerUrl[net.Name] resp, err := http.Post(fmt.Sprintf("%s/tx", baseUrl), "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 getNetwork() (*common.Network, *network.Network) { state, err := getState() if err != nil { return &common.TestNet, &network.Testnet } net, ok := state["network"] if !ok { return &common.MainNet, &network.Liquid } if net == "testnet" { return &common.TestNet, &network.Testnet } return &common.MainNet, &network.Liquid } func getAddress() (offchainAddr, onchainAddr string, err error) { publicKey, err := getWalletPublicKey() if err != nil { return } aspPublicKey, err := getServiceProviderPublicKey() if err != nil { return } arkNet, liquidNet := getNetwork() arkAddr, err := common.EncodeAddress(arkNet.Addr, publicKey, aspPublicKey) if err != nil { return } p2wpkh := payment.FromPublicKey(publicKey, liquidNet, nil) liquidAddr, err := p2wpkh.WitnessPubKeyHash() if err != nil { return } offchainAddr = arkAddr onchainAddr = liquidAddr return } func printJSON(resp interface{}) error { jsonBytes, err := json.MarshalIndent(resp, "", "\t") if err != nil { return err } fmt.Println(string(jsonBytes)) return nil } func handleRoundStream( ctx *cli.Context, client arkv1.ArkServiceClient, paymentID string, vtxosToSign []vtxo, 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, client, pingReq) } defer pingStop() for { event, err := stream.Recv() if err == io.EOF { break } if err != nil { return "", err } if event.GetRoundFailed() != nil { pingStop() return "", fmt.Errorf("round failed: %s", event.GetRoundFailed().GetReason()) } if event.GetRoundFinalization() != nil { // stop pinging as soon as we receive some forfeit txs pingStop() fmt.Println("round finalization started") poolPartialTx := event.GetRoundFinalization().GetPoolPartialTx() poolTransaction, err := psetv2.NewPsetFromBase64(poolPartialTx) if err != nil { return "", err } congestionTree, err := toCongestionTree(event.GetRoundFinalization().GetCongestionTree()) if err != nil { return "", err } aspPublicKey, err := getServiceProviderPublicKey() if err != nil { return "", err } seconds, err := getLifetime() if err != nil { return "", err } // validate the congestion tree if err := tree.ValidateCongestionTree( congestionTree, poolPartialTx, aspPublicKey, int64(seconds), ); err != nil { return "", err } exitDelay, err := getExitDelay() 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 poolTransaction.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, aspPublicKey, uint(exitDelay)) 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") forfeits := event.GetRoundFinalization().GetForfeitTxs() signedForfeits := make([]string, 0) fmt.Print("signing forfeit txs... ") explorer := NewExplorer() 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 { if err := signPset(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, client, pingReq) } continue } fmt.Printf("%d signed\n", len(signedForfeits)) fmt.Print("finalizing payment... ") _, err = client.FinalizePayment(ctx.Context, &arkv1.FinalizePaymentRequest{ SignedForfeitTxs: signedForfeits, }) 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 *cli.Context, client arkv1.ArkServiceClient, req *arkv1.PingRequest) func() { _, err := client.Ping(ctx.Context, 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.Context, req) } }(ticker) return ticker.Stop } 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 } // castCongestionTree converts a tree.CongestionTree to a repeated arkv1.TreeLevel func castCongestionTree(congestionTree tree.CongestionTree) *arkv1.Tree { levels := make([]*arkv1.TreeLevel, 0, len(congestionTree)) for _, level := range congestionTree { levelProto := &arkv1.TreeLevel{ Nodes: make([]*arkv1.Node, 0, len(level)), } for _, node := range level { levelProto.Nodes = append(levelProto.Nodes, &arkv1.Node{ Txid: node.Txid, Tx: node.Tx, ParentTxid: node.ParentTxid, }) } levels = append(levels, levelProto) } return &arkv1.Tree{ Levels: levels, } } func decodeReceiverAddress(addr string) ( isOnChainAddress bool, onchainScript []byte, userPubKey *secp256k1.PublicKey, err error, ) { outputScript, err := address.ToOutputScript(addr) if err != nil { _, userPubKey, _, err = common.DecodeAddress(addr) if err != nil { return } return false, nil, userPubKey, nil } return true, outputScript, nil, nil } func findSweepClosure( congestionTree tree.CongestionTree, ) (sweepClosure *taproot.TapElementsLeaf, seconds uint, err error) { root, err := congestionTree.Root() if err != nil { return } // find the sweep closure tx, err := psetv2.NewPsetFromBase64(root.Tx) if err != nil { return } 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 } func getRedeemBranches( ctx *cli.Context, explorer Explorer, client arkv1.ArkServiceClient, vtxos []vtxo, ) (map[string]RedeemBranch, error) { congestionTrees := make(map[string]tree.CongestionTree, 0) // poolTxid -> congestionTree redeemBranches := make(map[string]RedeemBranch, 0) // vtxo.txid -> redeemBranch for _, vtxo := range vtxos { if _, ok := congestionTrees[vtxo.poolTxid]; !ok { round, err := client.GetRound(ctx.Context, &arkv1.GetRoundRequest{ Txid: vtxo.poolTxid, }) if err != nil { return nil, err } treeFromRound := round.GetRound().GetCongestionTree() congestionTree, err := toCongestionTree(treeFromRound) if err != nil { return nil, err } congestionTrees[vtxo.poolTxid] = congestionTree } redeemBranch, err := newRedeemBranch(ctx, explorer, congestionTrees[vtxo.poolTxid], vtxo) if err != nil { return nil, err } redeemBranches[vtxo.txid] = redeemBranch } return redeemBranches, nil } func computeVtxoTaprootScript( userPubKey *secp256k1.PublicKey, aspPublicKey *secp256k1.PublicKey, exitDelay uint, ) (*secp256k1.PublicKey, *taproot.TapscriptElementsProof, error) { redeemClosure := &tree.CSVSigClosure{ Pubkey: userPubKey, Seconds: exitDelay, } forfeitClosure := &tree.ForfeitClosure{ Pubkey: userPubKey, AspPubkey: aspPublicKey, } 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 } func addVtxoInput( updater *psetv2.Updater, inputArgs psetv2.InputArgs, exitDelay uint, tapLeafProof *taproot.TapscriptElementsProof, ) error { sequence, err := common.BIP68EncodeAsNumber(exitDelay) if err != nil { return nil } nextInputIndex := len(updater.Pset.Inputs) if err := updater.AddInputs([]psetv2.InputArgs{inputArgs}); err != nil { return err } updater.Pset.Inputs[nextInputIndex].Sequence = sequence return updater.AddInTapLeafScript( nextInputIndex, psetv2.NewTapLeafScript( *tapLeafProof, tree.UnspendableKey(), ), ) } func coinSelectOnchain(targetAmount uint64, exclude []utxo) (utxos []utxo, delayedUtxos []utxo, change uint64, err error) { _, onchainAddr, err := getAddress() if err != nil { return nil, nil, 0, err } fromExplorer, err := getOnchainUtxos(onchainAddr) if err != nil { return nil, nil, 0, err } utxos = make([]utxo, 0) selectedAmount := uint64(0) for _, utxo := range fromExplorer { if selectedAmount >= targetAmount { break } for _, excluded := range exclude { if utxo.Txid == excluded.Txid && utxo.Vout == excluded.Vout { continue } } utxos = append(utxos, utxo) selectedAmount += utxo.Amount } if selectedAmount >= targetAmount { return utxos, nil, selectedAmount - targetAmount, nil } userPubKey, err := getWalletPublicKey() if err != nil { return nil, nil, 0, err } aspPublicKey, err := getServiceProviderPublicKey() if err != nil { return nil, nil, 0, err } exitDelay, err := getExitDelay() if err != nil { return nil, nil, 0, err } vtxoTapKey, _, err := computeVtxoTaprootScript(userPubKey, aspPublicKey, uint(exitDelay)) if err != nil { return nil, nil, 0, err } _, net := getNetwork() pay, err := payment.FromTweakedKey(vtxoTapKey, net, nil) if err != nil { return nil, nil, 0, err } addr, err := pay.TaprootAddress() if err != nil { return nil, nil, 0, err } fromExplorer, err = getOnchainUtxos(addr) if err != nil { return nil, nil, 0, err } delayedUtxos = make([]utxo, 0) for _, utxo := range fromExplorer { if selectedAmount >= targetAmount { break } availableAt := time.Unix(utxo.Status.Blocktime, 0).Add(time.Duration(exitDelay) * time.Second) if availableAt.After(time.Now()) { continue } for _, excluded := range exclude { if utxo.Txid == excluded.Txid && utxo.Vout == excluded.Vout { continue } } delayedUtxos = append(delayedUtxos, utxo) selectedAmount += utxo.Amount } if selectedAmount < targetAmount { return nil, nil, 0, fmt.Errorf("insufficient balance: %d to cover %d", selectedAmount, targetAmount) } return utxos, delayedUtxos, selectedAmount - targetAmount, nil } func addInputs( updater *psetv2.Updater, selected []utxo, // the utxos to add owned by the P2WPKH script delayedSelected []utxo, // the utxos to add owned by the VTXO script net *network.Network, ) error { _, onchainAddr, err := getAddress() if err != nil { return err } changeScript, err := address.ToOutputScript(onchainAddr) if err != nil { return err } for _, coin := range selected { fmt.Println("adding input", coin.Txid, coin.Vout) if err := updater.AddInputs([]psetv2.InputArgs{ { Txid: coin.Txid, TxIndex: coin.Vout, }, }); err != nil { return err } assetID, err := elementsutil.AssetHashToBytes(coin.Asset) if err != nil { return err } value, err := elementsutil.ValueToBytes(coin.Amount) if err != nil { return err } witnessUtxo := transaction.TxOutput{ Asset: assetID, Value: value, Script: changeScript, Nonce: []byte{0x00}, } if err := updater.AddInWitnessUtxo(len(updater.Pset.Inputs)-1, &witnessUtxo); err != nil { return err } } if len(delayedSelected) > 0 { userPubKey, err := getWalletPublicKey() if err != nil { return err } aspPublicKey, err := getServiceProviderPublicKey() if err != nil { return err } exitDelay, err := getExitDelay() if err != nil { return err } vtxoTapKey, leafProof, err := computeVtxoTaprootScript(userPubKey, aspPublicKey, uint(exitDelay)) if err != nil { return err } pay, err := payment.FromTweakedKey(vtxoTapKey, net, nil) if err != nil { return err } addr, err := pay.TaprootAddress() if err != nil { return err } script, err := address.ToOutputScript(addr) if err != nil { return err } for _, coin := range delayedSelected { if err := addVtxoInput( updater, psetv2.InputArgs{ Txid: coin.Txid, TxIndex: coin.Vout, }, uint(exitDelay), leafProof, ); err != nil { return err } assetID, err := elementsutil.AssetHashToBytes(coin.Asset) if err != nil { return err } value, err := elementsutil.ValueToBytes(coin.Amount) if err != nil { return err } witnessUtxo := transaction.TxOutput{ Asset: assetID, Value: value, Script: script, Nonce: []byte{0x00}, } if err := updater.AddInWitnessUtxo(len(updater.Pset.Inputs)-1, &witnessUtxo); err != nil { return err } } } return nil }