diff --git a/.github/workflows/ark.intergation.yaml b/.github/workflows/ark.integration.yaml similarity index 84% rename from .github/workflows/ark.intergation.yaml rename to .github/workflows/ark.integration.yaml index 1955102..ce4b850 100755 --- a/.github/workflows/ark.intergation.yaml +++ b/.github/workflows/ark.integration.yaml @@ -20,6 +20,10 @@ jobs: go-version: ">1.17.2" - uses: actions/checkout@v3 - run: go get -v -t -d ./... + + - name: Run Nigiri + uses: vulpemventures/nigiri-github-action@v1 + - name: integration testing run: make integrationtest \ No newline at end of file diff --git a/client/common.go b/client/common.go index 4f507ba..74859fd 100644 --- a/client/common.go +++ b/client/common.go @@ -20,6 +20,7 @@ import ( "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" @@ -34,6 +35,13 @@ const ( DUST = 450 ) +var passwordFlag = cli.StringFlag{ + Name: "password", + Usage: "password to unlock the wallet", + Required: false, + Hidden: true, +} + func hashPassword(password []byte) []byte { hash := sha256.Sum256(password) return hash[:] @@ -64,22 +72,30 @@ func verifyPassword(password []byte) error { 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 +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 err := verifyPassword(passwordInput); err != nil { - return nil, err + if verify { + if err := verifyPassword(password); err != nil { + return nil, err + } } - return passwordInput, nil + return password, nil } -func privateKeyFromPassword() (*secp256k1.PrivateKey, error) { +func privateKeyFromPassword(ctx *cli.Context) (*secp256k1.PrivateKey, error) { state, err := getState() if err != nil { return nil, err @@ -95,7 +111,7 @@ func privateKeyFromPassword() (*secp256k1.PrivateKey, error) { return nil, fmt.Errorf("invalid encrypted private key: %s", err) } - password, err := readPassword() + password, err := readPassword(ctx, true) if err != nil { return nil, err } @@ -256,9 +272,25 @@ func getOffchainBalance( return balance, amountByExpiration, nil } +func getBaseURL() (string, error) { + state, err := getState() + if err != nil { + return "", err + } + + baseURL := state[EXPLORER] + if len(baseURL) <= 0 { + return "", fmt.Errorf("missing explorer base url") + } + + return baseURL, nil +} + func getTxBlocktime(txid string) (confirmed bool, blocktime int64, err error) { - _, net := getNetwork() - baseUrl := explorerUrl[net.Name] + baseUrl, err := getBaseURL() + if err != nil { + return false, 0, err + } resp, err := http.Get(fmt.Sprintf("%s/tx/%s", baseUrl, txid)) if err != nil { return false, 0, err @@ -301,9 +333,16 @@ func getNetwork() (*common.Network, *network.Network) { if !ok { return &common.MainNet, &network.Liquid } + return networkFromString(net) +} + +func networkFromString(net string) (*common.Network, *network.Network) { if net == "testnet" { return &common.TestNet, &network.Testnet } + if net == "regtest" { + return &common.RegTest, &network.Regtest + } return &common.MainNet, &network.Liquid } @@ -432,11 +471,13 @@ func handleRoundStream( return "", err } - // validate the congestion tree - if err := tree.ValidateCongestionTree( - congestionTree, poolTx, aspPubkey, int64(roundLifetime), - ); err != nil { - return "", err + if !isOnchainOnly(receivers) { + // validate the congestion tree + if err := tree.ValidateCongestionTree( + congestionTree, poolTx, aspPubkey, int64(roundLifetime), + ); err != nil { + return "", err + } } if err := common.ValidateConnectors(poolTx, connectors); err != nil { @@ -772,7 +813,7 @@ func getRedeemBranches( } redeemBranch, err := newRedeemBranch( - ctx, explorer, congestionTrees[vtxo.poolTxid], vtxo, + explorer, congestionTrees[vtxo.poolTxid], vtxo, ) if err != nil { return nil, err @@ -1060,15 +1101,10 @@ func addInputs( return err } - witnessUtxo := transaction.TxOutput{ - Asset: assetID, - Value: value, - Script: script, - Nonce: []byte{0x00}, - } + witnessUtxo := transaction.NewTxOutput(assetID, value, script) if err := updater.AddInWitnessUtxo( - len(updater.Pset.Inputs)-1, &witnessUtxo, + len(updater.Pset.Inputs)-1, witnessUtxo, ); err != nil { return err } @@ -1077,3 +1113,18 @@ func addInputs( 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 +} diff --git a/client/dump.go b/client/dump.go index 65ab86d..e8ebde3 100644 --- a/client/dump.go +++ b/client/dump.go @@ -10,10 +10,11 @@ var dumpCommand = cli.Command{ Name: "dump-privkey", Usage: "Dumps private key of the Ark wallet", Action: dumpAction, + Flags: []cli.Flag{&passwordFlag}, } func dumpAction(ctx *cli.Context) error { - privateKey, err := privateKeyFromPassword() + privateKey, err := privateKeyFromPassword(ctx) if err != nil { return err } diff --git a/client/explorer.go b/client/explorer.go index 933ca73..123fd26 100644 --- a/client/explorer.go +++ b/client/explorer.go @@ -40,8 +40,10 @@ type explorer struct { } func NewExplorer() Explorer { - _, net := getNetwork() - baseUrl := explorerUrl[net.Name] + baseUrl, err := getBaseURL() + if err != nil { + panic(err) + } return &explorer{ cache: make(map[string]string), @@ -76,10 +78,7 @@ func (e *explorer) Broadcast(txStr string) (string, error) { if err != nil { return "", err } - txStr, err = tx.ToHex() - if err != nil { - return "", err - } + txStr, _ = tx.ToHex() } txid := tx.TxHash().String() e.cache[txid] = txStr diff --git a/client/init.go b/client/init.go index ee3e038..705ded8 100644 --- a/client/init.go +++ b/client/init.go @@ -4,6 +4,8 @@ import ( "context" "encoding/hex" "fmt" + "io" + "net/http" "strconv" "strings" @@ -11,15 +13,10 @@ import ( "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 ( - passwordFlag = cli.StringFlag{ - Name: "password", - Usage: "password to encrypt private key", - Required: true, - } - privateKeyFlag = cli.StringFlag{ Name: "prvkey", Usage: "optional, private key to encrypt", @@ -34,35 +31,54 @@ var ( Usage: "the url of the ASP to connect to", Required: true, } + explorerFlag = cli.StringFlag{ + Name: "explorer", + Usage: "the url of the explorer to use", + } ) var initCommand = cli.Command{ Name: "init", Usage: "Initialize your Ark wallet with an encryption password, and connect it to an ASP", Action: initAction, - Flags: []cli.Flag{&passwordFlag, &privateKeyFlag, &networkFlag, &urlFlag}, + Flags: []cli.Flag{&passwordFlag, &privateKeyFlag, &networkFlag, &urlFlag, &explorerFlag}, } func initAction(ctx *cli.Context) error { key := ctx.String("prvkey") - password := ctx.String("password") net := strings.ToLower(ctx.String("network")) url := ctx.String("ark-url") + explorer := ctx.String("explorer") + + var explorerURL string - if len(password) <= 0 { - return fmt.Errorf("invalid password") - } if len(url) <= 0 { return fmt.Errorf("invalid ark url") } - if net != "mainnet" && net != "testnet" { + if net != "mainnet" && net != "testnet" && net != "regtest" { return fmt.Errorf("invalid network") } - if err := connectToAsp(ctx.Context, net, url); err != nil { + if len(explorer) > 0 { + explorerURL = explorer + _, network := networkFromString(net) + if err := testEsploraEndpoint(network, explorerURL); err != nil { + return fmt.Errorf("failed to connect with explorer: %s", err) + } + } else { + explorerURL = explorerUrl[net] + } + + if err := connectToAsp(ctx.Context, net, url, explorerURL); err != nil { return err } - return initWallet(ctx, key, password) + + password, err := readPassword(ctx, false) + if err != nil { + return err + } + + return initWallet(key, password) } func generateRandomPrivateKey() (*secp256k1.PrivateKey, error) { @@ -73,7 +89,7 @@ func generateRandomPrivateKey() (*secp256k1.PrivateKey, error) { return privKey, nil } -func connectToAsp(ctx context.Context, net, url string) error { +func connectToAsp(ctx context.Context, net, url, explorer string) error { client, close, err := getClient(url) if err != nil { return err @@ -91,10 +107,11 @@ func connectToAsp(ctx context.Context, net, url string) error { ASP_PUBKEY: resp.Pubkey, ROUND_LIFETIME: strconv.Itoa(int(resp.GetRoundLifetime())), UNILATERAL_EXIT_DELAY: strconv.Itoa(int(resp.GetUnilateralExitDelay())), + EXPLORER: explorer, }) } -func initWallet(ctx *cli.Context, key, password string) error { +func initWallet(key string, password []byte) error { var privateKey *secp256k1.PrivateKey if len(key) <= 0 { privKey, err := generateRandomPrivateKey() @@ -113,7 +130,7 @@ func initWallet(ctx *cli.Context, key, password string) error { cypher := newAES128Cypher() buf := privateKey.Serialize() - encryptedPrivateKey, err := cypher.encrypt(buf, []byte(password)) + encryptedPrivateKey, err := cypher.encrypt(buf, password) if err != nil { return err } @@ -134,3 +151,20 @@ func initWallet(ctx *cli.Context, key, password string) error { fmt.Println("wallet initialized") return nil } + +func testEsploraEndpoint(net *network.Network, url string) error { + resp, err := http.Get(fmt.Sprintf("%s/asset/%s", url, net.AssetID)) + 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)) + } + + return nil +} diff --git a/client/main.go b/client/main.go index 45ce543..2591482 100644 --- a/client/main.go +++ b/client/main.go @@ -26,6 +26,7 @@ const ( PASSWORD_HASH = "password_hash" PUBKEY = "public_key" NETWORK = "network" + EXPLORER = "explorer" ) var ( @@ -36,6 +37,7 @@ var ( explorerUrl = map[string]string{ network.Liquid.Name: "https://blockstream.info/liquid/api", network.Testnet.Name: "https://blockstream.info/liquidtestnet/api", + network.Regtest.Name: "http://localhost:3001", } initialState = map[string]string{ diff --git a/client/onboard.go b/client/onboard.go index 5d41c2d..c1862c1 100644 --- a/client/onboard.go +++ b/client/onboard.go @@ -27,7 +27,7 @@ var onboardCommand = cli.Command{ Name: "onboard", Usage: "Onboard the Ark by lifting your funds", Action: onboardAction, - Flags: []cli.Flag{&amountOnboardFlag}, + Flags: []cli.Flag{&amountOnboardFlag, &passwordFlag}, } func onboardAction(ctx *cli.Context) error { @@ -92,11 +92,9 @@ func onboardAction(ctx *cli.Context) error { return err } - explorer := NewExplorer() - txid, err := explorer.Broadcast(pset) - if err != nil { - return err - } + ptx, _ := psetv2.NewPsetFromBase64(pset) + utx, _ := ptx.UnsignedTx() + txid := utx.TxHash().String() congestionTree, err := treeFactoryFn(psetv2.InputArgs{ Txid: txid, diff --git a/client/redeem.go b/client/redeem.go index 066e367..425f851 100644 --- a/client/redeem.go +++ b/client/redeem.go @@ -2,7 +2,6 @@ package main import ( "bufio" - "context" "fmt" "log" "os" @@ -40,7 +39,7 @@ var ( var redeemCommand = cli.Command{ Name: "redeem", Usage: "Redeem your offchain funds, either collaboratively or unilaterally", - Flags: []cli.Flag{&addressFlag, &amountToRedeemFlag, &forceFlag, &enableExpiryCoinselectFlag}, + Flags: []cli.Flag{&addressFlag, &amountToRedeemFlag, &forceFlag, &passwordFlag, &enableExpiryCoinselectFlag}, Action: redeemAction, } @@ -68,7 +67,7 @@ func redeemAction(ctx *cli.Context) error { fmt.Printf("WARNING: unilateral exit (--force) ignores --amount flag, it will redeem all your VTXOs\n") } - return unilateralRedeem(ctx.Context, client) + return unilateralRedeem(ctx, client) } return collaborativeRedeem(ctx, client, addr, amount) @@ -137,7 +136,7 @@ func collaborativeRedeem( }) } - secKey, err := privateKeyFromPassword() + secKey, err := privateKeyFromPassword(ctx) if err != nil { return err } @@ -178,14 +177,14 @@ func collaborativeRedeem( return nil } -func unilateralRedeem(ctx context.Context, client arkv1.ArkServiceClient) error { +func unilateralRedeem(ctx *cli.Context, client arkv1.ArkServiceClient) error { offchainAddr, _, _, err := getAddress() if err != nil { return err } explorer := NewExplorer() - vtxos, err := getVtxos(ctx, explorer, client, offchainAddr, false) + vtxos, err := getVtxos(ctx.Context, explorer, client, offchainAddr, false) if err != nil { return err } @@ -196,16 +195,18 @@ func unilateralRedeem(ctx context.Context, client arkv1.ArkServiceClient) error totalVtxosAmount += vtxo.amount } - ok := askForConfirmation(fmt.Sprintf("redeem %d sats ?", totalVtxosAmount)) - if !ok { - return fmt.Errorf("aborting unilateral exit") + 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, explorer, client, vtxos) + redeemBranches, err := getRedeemBranches(ctx.Context, explorer, client, vtxos) if err != nil { return err } diff --git a/client/send.go b/client/send.go index 762da1d..0fc5afc 100644 --- a/client/send.go +++ b/client/send.go @@ -47,7 +47,7 @@ var sendCommand = cli.Command{ Name: "send", Usage: "Send your onchain or offchain funds to one or many receivers", Action: sendAction, - Flags: []cli.Flag{&receiversFlag, &toFlag, &amountFlag, &enableExpiryCoinselectFlag}, + Flags: []cli.Flag{&receiversFlag, &toFlag, &amountFlag, &passwordFlag, &enableExpiryCoinselectFlag}, } func sendAction(ctx *cli.Context) error { @@ -186,7 +186,7 @@ func sendOffchain(ctx *cli.Context, receivers []receiver) error { }) } - secKey, err := privateKeyFromPassword() + secKey, err := privateKeyFromPassword(ctx) if err != nil { return err } @@ -219,7 +219,7 @@ func sendOffchain(ctx *cli.Context, receivers []receiver) error { }) } -func sendOnchain(_ *cli.Context, receivers []receiver) (string, error) { +func sendOnchain(ctx *cli.Context, receivers []receiver) (string, error) { pset, err := psetv2.New(nil, nil, nil) if err != nil { return "", err @@ -349,7 +349,7 @@ func sendOnchain(_ *cli.Context, receivers []receiver) (string, error) { return "", err } - prvKey, err := privateKeyFromPassword() + prvKey, err := privateKeyFromPassword(ctx) if err != nil { return "", err } diff --git a/client/unilateral_redeem.go b/client/unilateral_redeem.go index baffab0..205f804 100644 --- a/client/unilateral_redeem.go +++ b/client/unilateral_redeem.go @@ -1,7 +1,6 @@ package main import ( - "context" "fmt" "time" @@ -22,7 +21,7 @@ type redeemBranch struct { } func newRedeemBranch( - ctx context.Context, explorer Explorer, + explorer Explorer, congestionTree tree.CongestionTree, vtxo vtxo, ) (*redeemBranch, error) { sweepClosure, seconds, err := findSweepClosure(congestionTree) diff --git a/common/network.go b/common/network.go index eec92ba..cabbb50 100644 --- a/common/network.go +++ b/common/network.go @@ -14,3 +14,8 @@ var TestNet = Network{ Name: "testnet", Addr: "tark", } + +var RegTest = Network{ + Name: "regtest", + Addr: TestNet.Addr, +} diff --git a/docker-compose.regtest.yml b/docker-compose.regtest.yml new file mode 100644 index 0000000..17b99bf --- /dev/null +++ b/docker-compose.regtest.yml @@ -0,0 +1,47 @@ +version: "3.7" + +services: + oceand: + container_name: oceand + image: ghcr.io/vulpemventures/oceand:latest + restart: unless-stopped + user: 0:0 + environment: + - OCEAN_LOG_LEVEL=5 + - OCEAN_NO_TLS=true + - OCEAN_NO_PROFILER=true + - OCEAN_ELECTRUM_URL=tcp://electrs-liquid:50001 + - OCEAN_NETWORK=regtest + - OCEAN_UTXO_EXPIRY_DURATION_IN_SECONDS=60 + - OCEAN_DB_TYPE=badger + ports: + - "18000:18000" + arkd: + container_name: arkd + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + - oceand + environment: + - ARK_WALLET_ADDR=oceand:18000 + - ARK_ROUND_INTERVAL=10 + - ARK_NETWORK=regtest + ports: + - "6000:6000" + +volumes: + oceand: + external: false + ocean: + external: false + arkd: + external: false + ark: + external: false + +networks: + default: + name: nigiri + external: true \ No newline at end of file diff --git a/server/Makefile b/server/Makefile index 5d1a433..1264edf 100755 --- a/server/Makefile +++ b/server/Makefile @@ -23,7 +23,7 @@ help: ## intergrationtest: runs integration tests integrationtest: @echo "Running integration tests..." - @find . -name go.mod -execdir go test -v -count=1 -race $(go list ./... | grep internal/test) \; + @go test -v -count=1 -race -timeout 200s github.com/ark-network/ark/test/e2e ## lint: lint codebase lint: @@ -40,7 +40,7 @@ run: clean ## test: runs unit and component tests test: @echo "Running unit tests..." - @find . -name go.mod -execdir go test -v -count=1 -race ./... $(go list ./... | grep -v internal/test) \; + @find . -name go.mod -execdir go test -v -count=1 -race ./internal/... \; ## vet: code analysis vet: diff --git a/server/internal/app-config/config.go b/server/internal/app-config/config.go index 5c99b9b..0efd480 100644 --- a/server/internal/app-config/config.go +++ b/server/internal/app-config/config.go @@ -69,8 +69,8 @@ func (c *Config) Validate() error { if c.RoundInterval < 2 { return fmt.Errorf("invalid round interval, must be at least 2 seconds") } - if c.Network.Name != "liquid" && c.Network.Name != "testnet" { - return fmt.Errorf("invalid network, must be either liquid or testnet") + if c.Network.Name != "liquid" && c.Network.Name != "testnet" && c.Network.Name != "regtest" { + return fmt.Errorf("invalid network, must be liquid, testnet or regtest") } if len(c.WalletAddr) <= 0 { return fmt.Errorf("missing onchain wallet address") @@ -239,11 +239,14 @@ func (c *Config) appService() error { } func (c *Config) mainChain() network.Network { - net := network.Liquid - if c.Network.Name != "mainnet" { - net = network.Testnet + switch c.Network.Name { + case "testnet": + return network.Testnet + case "regtest": + return network.Regtest + default: + return network.Liquid } - return net } type supportedType map[string]struct{} diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 9ad38f3..e52f46f 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -121,7 +121,9 @@ func getNetwork() (common.Network, error) { return common.MainNet, nil case "testnet": return common.TestNet, nil + case "regtest": + return common.RegTest, nil default: - return common.Network{}, fmt.Errorf("unknown network") + return common.Network{}, fmt.Errorf("unknown network %s", viper.GetString(Network)) } } diff --git a/server/internal/core/application/service.go b/server/internal/core/application/service.go index 21e6518..f41c68b 100644 --- a/server/internal/core/application/service.go +++ b/server/internal/core/application/service.go @@ -415,7 +415,7 @@ func (s *service) handleOnboarding(onboarding onboarding) { } if err != nil || !isConfirmed { - time.Sleep(30 * time.Second) + time.Sleep(5 * time.Second) } } } @@ -614,28 +614,30 @@ func (s *service) updateVtxoSet(round *domain.Round) { } newVtxos := s.getNewVtxos(round) - for { - if err := repo.AddVtxos(ctx, newVtxos); err != nil { - log.WithError(err).Warn("failed to add new vtxos, retrying soon") - time.Sleep(100 * time.Millisecond) - continue - } - log.Debugf("added %d new vtxos", len(newVtxos)) - break - } - - go func() { + if len(newVtxos) > 0 { for { - if err := s.startWatchingVtxos(newVtxos); err != nil { - log.WithError(err).Warn( - "failed to start watching vtxos, retrying in a moment...", - ) + if err := repo.AddVtxos(ctx, newVtxos); err != nil { + log.WithError(err).Warn("failed to add new vtxos, retrying soon") + time.Sleep(100 * time.Millisecond) continue } - log.Debugf("started watching %d vtxos", len(newVtxos)) - return + log.Debugf("added %d new vtxos", len(newVtxos)) + break } - }() + + go func() { + for { + if err := s.startWatchingVtxos(newVtxos); err != nil { + log.WithError(err).Warn( + "failed to start watching vtxos, retrying in a moment...", + ) + continue + } + log.Debugf("started watching %d vtxos", len(newVtxos)) + return + } + }() + } } func (s *service) propagateEvents(round *domain.Round) { @@ -673,6 +675,10 @@ func (s *service) scheduleSweepVtxosForRound(round *domain.Round) { } func (s *service) getNewVtxos(round *domain.Round) []domain.Vtxo { + if len(round.CongestionTree) <= 0 { + return nil + } + leaves := round.CongestionTree.Leaves() vtxos := make([]domain.Vtxo, 0) for _, node := range leaves { diff --git a/server/internal/core/application/sweeper.go b/server/internal/core/application/sweeper.go index efa9f92..480f075 100644 --- a/server/internal/core/application/sweeper.go +++ b/server/internal/core/application/sweeper.go @@ -71,6 +71,11 @@ func (s *sweeper) removeTask(treeRootTxid string) { func (s *sweeper) schedule( expirationTimestamp int64, roundTxid string, congestionTree tree.CongestionTree, ) error { + if len(congestionTree) <= 0 { // skip + log.Debugf("skipping sweep scheduling (round tx %s), empty congestion tree", roundTxid) + return nil + } + root, err := congestionTree.Root() if err != nil { return err diff --git a/server/internal/core/domain/round.go b/server/internal/core/domain/round.go index 128354b..9a96f76 100644 --- a/server/internal/core/domain/round.go +++ b/server/internal/core/domain/round.go @@ -181,12 +181,6 @@ func (r *Round) RegisterPayments(payments []Payment) ([]RoundEvent, error) { } func (r *Round) StartFinalization(connectorAddress string, connectors []string, congestionTree tree.CongestionTree, poolTx string) ([]RoundEvent, error) { - if len(connectors) <= 0 { - return nil, fmt.Errorf("missing list of connectors") - } - if len(congestionTree) <= 0 { - return nil, fmt.Errorf("missing congestion tree") - } if len(poolTx) <= 0 { return nil, fmt.Errorf("missing unsigned pool tx") } diff --git a/server/internal/core/domain/round_test.go b/server/internal/core/domain/round_test.go index 55de389..7b6b3f2 100644 --- a/server/internal/core/domain/round_test.go +++ b/server/internal/core/domain/round_test.go @@ -323,32 +323,6 @@ func testStartFinalization(t *testing.T) { poolTx string expectedErr string }{ - { - round: &domain.Round{ - Id: "0", - Stage: domain.Stage{ - Code: domain.RegistrationStage, - }, - Payments: paymentsById, - }, - connectors: nil, - tree: congestionTree, - poolTx: poolTx, - expectedErr: "missing list of connectors", - }, - { - round: &domain.Round{ - Id: "0", - Stage: domain.Stage{ - Code: domain.RegistrationStage, - }, - Payments: paymentsById, - }, - connectors: connectors, - tree: nil, - poolTx: poolTx, - expectedErr: "missing congestion tree", - }, { round: &domain.Round{ Id: "0", diff --git a/server/internal/infrastructure/db/badger/utils.go b/server/internal/infrastructure/db/badger/utils.go index 0dee5e7..ee657b0 100644 --- a/server/internal/infrastructure/db/badger/utils.go +++ b/server/internal/infrastructure/db/badger/utils.go @@ -95,7 +95,7 @@ func deserializeEvent(buf []byte) (domain.RoundEvent, error) { } { var event = domain.RoundFinalizationStarted{} - if err := json.Unmarshal(buf, &event); err == nil && len(event.CongestionTree) > 0 { + if err := json.Unmarshal(buf, &event); err == nil && len(event.Connectors) > 0 { return event, nil } } diff --git a/server/internal/infrastructure/ocean-wallet/service.go b/server/internal/infrastructure/ocean-wallet/service.go index 6ed739d..94eda1f 100644 --- a/server/internal/infrastructure/ocean-wallet/service.go +++ b/server/internal/infrastructure/ocean-wallet/service.go @@ -2,9 +2,9 @@ package oceanwallet import ( "context" - "fmt" "io" "strings" + "time" pb "github.com/ark-network/ark/api-spec/protobuf/gen/ocean/v1" "github.com/ark-network/ark/internal/core/domain" @@ -47,12 +47,21 @@ func NewService(addr string) (ports.WalletService, error) { } ctx := context.Background() - status, err := svc.Status(ctx) - if err != nil { - return nil, err - } - if !(status.IsInitialized() && status.IsUnlocked()) { - return nil, fmt.Errorf("wallet must be already initialized and unlocked") + + isReady := false + + for !isReady { + status, err := svc.Status(ctx) + if err != nil { + return nil, err + } + + isReady = status.IsInitialized() && status.IsUnlocked() + + if !isReady { + log.Info("Wallet must be initialized and unlocked to proceed. Waiting for wallet to be ready...") + time.Sleep(3 * time.Second) + } } // Create ark account at startup if needed. diff --git a/server/internal/infrastructure/tx-builder/covenant/builder.go b/server/internal/infrastructure/tx-builder/covenant/builder.go index 1de9fe8..e0ad939 100644 --- a/server/internal/infrastructure/tx-builder/covenant/builder.go +++ b/server/internal/infrastructure/tx-builder/covenant/builder.go @@ -119,11 +119,18 @@ func (b *txBuilder) BuildPoolTx( // generated in the process and takes the shared utxo outpoint as argument. // This is safe as the memory allocated for `craftCongestionTree` is freed // only after `BuildPoolTx` returns. - treeFactoryFn, sharedOutputScript, sharedOutputAmount, err := tree.CraftCongestionTree( - b.net.AssetID, aspPubkey, getOffchainReceivers(payments), minRelayFee, b.roundLifetime, b.exitDelay, - ) - if err != nil { - return + + var sharedOutputScript []byte + var sharedOutputAmount uint64 + var treeFactoryFn tree.TreeFactory + + if !isOnchainOnly(payments) { + treeFactoryFn, sharedOutputScript, sharedOutputAmount, err = tree.CraftCongestionTree( + b.net.AssetID, aspPubkey, getOffchainReceivers(payments), minRelayFee, b.roundLifetime, b.exitDelay, + ) + if err != nil { + return + } } connectorAddress, err = b.wallet.DeriveConnectorAddress(context.Background()) @@ -143,12 +150,14 @@ func (b *txBuilder) BuildPoolTx( return } - tree, err := treeFactoryFn(psetv2.InputArgs{ - Txid: unsignedTx.TxHash().String(), - TxIndex: 0, - }) - if err != nil { - return + if treeFactoryFn != nil { + congestionTree, err = treeFactoryFn(psetv2.InputArgs{ + Txid: unsignedTx.TxHash().String(), + TxIndex: 0, + }) + if err != nil { + return + } } poolTx, err = ptx.ToBase64() @@ -156,7 +165,6 @@ func (b *txBuilder) BuildPoolTx( return } - congestionTree = tree return } @@ -219,21 +227,26 @@ func (b *txBuilder) createPoolTx( if nbOfInputs > 1 { connectorsAmount -= minRelayFee } - targetAmount := sharedOutputAmount + connectorsAmount + targetAmount := connectorsAmount - outputs := []psetv2.OutputArgs{ - { + outputs := make([]psetv2.OutputArgs, 0) + + if sharedOutputScript != nil && sharedOutputAmount > 0 { + targetAmount += sharedOutputAmount + + outputs = append(outputs, psetv2.OutputArgs{ Asset: b.net.AssetID, Amount: sharedOutputAmount, Script: sharedOutputScript, - }, - { - Asset: b.net.AssetID, - Amount: connectorsAmount, - Script: connectorScript, - }, + }) } + outputs = append(outputs, psetv2.OutputArgs{ + Asset: b.net.AssetID, + Amount: connectorsAmount, + Script: connectorScript, + }) + for _, receiver := range receivers { targetAmount += receiver.Amount diff --git a/server/internal/infrastructure/tx-builder/covenant/utils.go b/server/internal/infrastructure/tx-builder/covenant/utils.go index 3bbcbde..de877db 100644 --- a/server/internal/infrastructure/tx-builder/covenant/utils.go +++ b/server/internal/infrastructure/tx-builder/covenant/utils.go @@ -139,3 +139,14 @@ func addInputs( func taprootOutputScript(taprootKey *secp256k1.PublicKey) ([]byte, error) { return txscript.NewScriptBuilder().AddOp(txscript.OP_1).AddData(schnorr.SerializePubKey(taprootKey)).Script() } + +func isOnchainOnly(payments []domain.Payment) bool { + for _, p := range payments { + for _, r := range p.Receivers { + if !r.IsOnchain() { + return false + } + } + } + return true +} diff --git a/server/internal/test/.gitkeep b/server/internal/test/.gitkeep deleted file mode 100755 index e69de29..0000000 diff --git a/server/test/e2e/e2e_test.go b/server/test/e2e/e2e_test.go new file mode 100644 index 0000000..66c98a2 --- /dev/null +++ b/server/test/e2e/e2e_test.go @@ -0,0 +1,205 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const composePath = "../../../docker-compose.regtest.yml" + +func TestMain(m *testing.M) { + _, err := runCommand("docker-compose", "-f", composePath, "up", "-d", "--build") + if err != nil { + fmt.Printf("error starting docker-compose: %s", err) + os.Exit(1) + } + + _, err = runOceanCommand("config", "init", "--no-tls") + if err != nil { + fmt.Printf("error initializing ocean config: %s", err) + os.Exit(1) + } + + _, err = runOceanCommand("wallet", "create", "--password", password) + if err != nil { + fmt.Printf("error creating ocean wallet: %s", err) + os.Exit(1) + } + + _, err = runOceanCommand("wallet", "unlock", "--password", password) + if err != nil { + fmt.Printf("error unlocking ocean wallet: %s", err) + os.Exit(1) + } + + _, err = runOceanCommand("account", "create", "--label", "ark", "--unconf") + if err != nil { + fmt.Printf("error creating ocean account: %s", err) + os.Exit(1) + } + + addrJSON, err := runOceanCommand("account", "derive", "--account-name", "ark") + if err != nil { + fmt.Printf("error deriving ocean account: %s", err) + os.Exit(1) + } + + var addr struct { + Addresses []string `json:"addresses"` + } + + if err := json.Unmarshal([]byte(addrJSON), &addr); err != nil { + fmt.Printf("error unmarshalling ocean account: %s (%s)", err, addrJSON) + os.Exit(1) + } + + _, err = runCommand("nigiri", "faucet", "--liquid", addr.Addresses[0]) + if err != nil { + fmt.Printf("error funding ocean account: %s", err) + os.Exit(1) + } + + time.Sleep(2 * time.Second) + + _, err = runArkCommand("init", "--ark-url", "localhost:6000", "--password", password, "--network", "regtest", "--explorer", "http://chopsticks-liquid:3000") + if err != nil { + fmt.Printf("error initializing ark config: %s", err) + os.Exit(1) + } + + var receive arkReceive + receiveStr, err := runArkCommand("receive") + if err != nil { + fmt.Printf("error getting ark receive addresses: %s", err) + os.Exit(1) + } + + if err := json.Unmarshal([]byte(receiveStr), &receive); err != nil { + fmt.Printf("error unmarshalling ark receive addresses: %s", err) + os.Exit(1) + } + + _, err = runCommand("nigiri", "faucet", "--liquid", receive.Onchain) + if err != nil { + fmt.Printf("error funding ark account: %s", err) + os.Exit(1) + } + + time.Sleep(5 * time.Second) + + code := m.Run() + + _, err = runCommand("docker-compose", "-f", composePath, "down") + if err != nil { + fmt.Printf("error stopping docker-compose: %s", err) + os.Exit(1) + } + os.Exit(code) +} + +func TestOnboard(t *testing.T) { + var balance arkBalance + balanceStr, err := runArkCommand("balance") + require.NoError(t, err) + + require.NoError(t, json.Unmarshal([]byte(balanceStr), &balance)) + balanceBefore := balance.Offchain.Total + + _, err = runArkCommand("onboard", "--amount", "1000", "--password", password) + require.NoError(t, err) + err = generateBlock() + require.NoError(t, err) + + balanceStr, err = runArkCommand("balance") + require.NoError(t, err) + + require.NoError(t, json.Unmarshal([]byte(balanceStr), &balance)) + require.Equal(t, balanceBefore+1000, balance.Offchain.Total) +} + +func TestSendOffchain(t *testing.T) { + _, err := runArkCommand("onboard", "--amount", "1000", "--password", password) + require.NoError(t, err) + err = generateBlock() + require.NoError(t, err) + + var receive arkReceive + receiveStr, err := runArkCommand("receive") + require.NoError(t, err) + require.NoError(t, json.Unmarshal([]byte(receiveStr), &receive)) + + _, err = runArkCommand("send", "--amount", "1000", "--to", receive.Offchain, "--password", password) + require.NoError(t, err) + + var balance arkBalance + balanceStr, err := runArkCommand("balance") + require.NoError(t, err) + require.NoError(t, json.Unmarshal([]byte(balanceStr), &balance)) + require.NotZero(t, balance.Offchain.Total) +} + +func TestUnilateralExit(t *testing.T) { + _, err := runArkCommand("onboard", "--amount", "1000", "--password", password) + require.NoError(t, err) + err = generateBlock() + require.NoError(t, err) + + var balance arkBalance + balanceStr, err := runArkCommand("balance") + require.NoError(t, err) + require.NoError(t, json.Unmarshal([]byte(balanceStr), &balance)) + require.NotZero(t, balance.Offchain.Total) + require.Len(t, balance.Onchain.Locked, 0) + + _, err = runArkCommand("redeem", "--force", "--password", password) + require.NoError(t, err) + + err = generateBlock() + require.NoError(t, err) + + balanceStr, err = runArkCommand("balance") + require.NoError(t, err) + require.NoError(t, json.Unmarshal([]byte(balanceStr), &balance)) + require.Zero(t, balance.Offchain.Total) + require.Len(t, balance.Onchain.Locked, 1) + + lockedBalance := balance.Onchain.Locked[0].Amount + require.NotZero(t, lockedBalance) +} + +func TestCollaborativeExit(t *testing.T) { + _, err := runArkCommand("onboard", "--amount", "1000", "--password", password) + require.NoError(t, err) + err = generateBlock() + require.NoError(t, err) + + var receive arkReceive + receiveStr, err := runArkCommand("receive") + require.NoError(t, err) + require.NoError(t, json.Unmarshal([]byte(receiveStr), &receive)) + + var balance arkBalance + balanceStr, err := runArkCommand("balance") + require.NoError(t, err) + require.NoError(t, json.Unmarshal([]byte(balanceStr), &balance)) + + balanceBefore := balance.Offchain.Total + balanceOnchainBefore := balance.Onchain.Spendable + + _, err = runArkCommand("redeem", "--amount", "1000", "--address", receive.Onchain, "--password", password) + require.NoError(t, err) + + time.Sleep(5 * time.Second) + + balanceStr, err = runArkCommand("balance") + require.NoError(t, err) + require.NoError(t, json.Unmarshal([]byte(balanceStr), &balance)) + + require.Equal(t, balanceBefore-1000, balance.Offchain.Total) + require.Equal(t, balanceOnchainBefore+1000, balance.Onchain.Spendable) +} diff --git a/server/test/e2e/utils.go b/server/test/e2e/utils.go new file mode 100644 index 0000000..bebff0a --- /dev/null +++ b/server/test/e2e/utils.go @@ -0,0 +1,109 @@ +package e2e + +import ( + "fmt" + "io" + "os/exec" + "strings" + "sync" + "time" +) + +const ( + password = "password" +) + +type arkBalance struct { + Offchain struct { + Total int `json:"total"` + } `json:"offchain_balance"` + Onchain struct { + Spendable int `json:"spendable_amount"` + Locked []struct { + Amount int `json:"amount"` + SpendableAt string `json:"spendable_at"` + } `json:"locked_amount"` + } `json:"onchain_balance"` +} + +type arkReceive struct { + Offchain string `json:"offchain_address"` + Onchain string `json:"onchain_address"` +} + +func runOceanCommand(arg ...string) (string, error) { + args := append([]string{"exec", "oceand", "ocean"}, arg...) + return runCommand("docker", args...) +} + +func runArkCommand(arg ...string) (string, error) { + args := append([]string{"exec", "-t", "arkd", "ark"}, arg...) + return runCommand("docker", args...) +} + +func generateBlock() error { + if _, err := runCommand("nigiri", "rpc", "--liquid", "generatetoaddress", "1", "el1qqwk722tghgkgmh3r2ph4d2apwj0dy9xnzlenzklx8jg3z299fpaw56trre9gpk6wmw0u4qycajqeva3t7lzp7wnacvwxha59r"); err != nil { + return err + } + + time.Sleep(6 * time.Second) + return nil +} + +func runCommand(name string, arg ...string) (string, error) { + errb := new(strings.Builder) + cmd := newCommand(name, arg...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", err + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return "", err + } + + if err := cmd.Start(); err != nil { + return "", err + } + output := new(strings.Builder) + errorb := new(strings.Builder) + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + io.Copy(output, stdout) + }() + + go func() { + defer wg.Done() + io.Copy(errorb, stderr) + }() + + wg.Wait() + if err := cmd.Wait(); err != nil { + if errMsg := errorb.String(); len(errMsg) > 0 { + return "", fmt.Errorf(errMsg) + } + + if outMsg := output.String(); len(outMsg) > 0 { + return "", fmt.Errorf(outMsg) + } + + return "", err + } + + if errMsg := errb.String(); len(errMsg) > 0 { + return "", fmt.Errorf(errMsg) + } + + return strings.Trim(output.String(), "\n"), nil +} + +func newCommand(name string, arg ...string) *exec.Cmd { + cmd := exec.Command(name, arg...) + return cmd +}