diff --git a/Cargo.toml b/Cargo.toml index 814863ff..5c5db488 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,3 +35,4 @@ tracing = "0.1.37" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } url = "2.4.1" wsts = "1.2" +blockstack-core = { git = "https://github.com/stacks-network/stacks-blockchain/", branch = "master" } diff --git a/Makefile.toml b/Makefile.toml index 8d8e90ae..643c1cf1 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -17,7 +17,7 @@ TEST_FRAMEWORK = "nextest" [env.github-actions] # TODO: use nexttest when testing becomes too much to do in series. # Installing nexttest takes > 5 minutes on github workflow machines. -TEST_FRAMEWORK = "test" +TEST_FRAMEWORK = "nextest" # Installations # -------------- @@ -86,7 +86,9 @@ args = [ "llvm-cov", "${TEST_FRAMEWORK}", "--all-features", - "--no-report" + "--no-report", + "-E", + "not (kind(test))", ] [tasks._calculated-coverage-clean] diff --git a/devenv/bitcoin/docker/Dockerfile b/devenv/bitcoin/docker/Dockerfile index c6394204..1bf79882 100644 --- a/devenv/bitcoin/docker/Dockerfile +++ b/devenv/bitcoin/docker/Dockerfile @@ -6,7 +6,7 @@ ARG VERSION=25.0 RUN apt-get update && apt-get install -y \ wget \ file \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* RUN file /bin/bash | grep -q x86-64 && echo x86_64-linux-gnu > /tmp/arch || true RUN file /bin/bash | grep -q aarch64 && echo aarch64-linux-gnu > /tmp/arch || true diff --git a/devenv/bitcoin/docker/entrypoint.sh b/devenv/bitcoin/docker/entrypoint.sh index f71462c4..c1987058 100644 --- a/devenv/bitcoin/docker/entrypoint.sh +++ b/devenv/bitcoin/docker/entrypoint.sh @@ -2,10 +2,5 @@ set -x -#-rpcuser=${BTC_RPCUSER} -rpcpassword=${BTC_RPCPASSWORD} - -# bitcoind needs creds set in the conf file for remote RPC auth -#echo '[regtest]' > ${BITCOIN_CONF} - nginx bitcoind -chain=${BTC_NETWORK} -txindex=${BTC_TXINDEX} -rpcuser=${BTC_RPCUSER} -rpcpassword=${BTC_RPCPASSWORD} -printtoconsole=${BTC_PRINTTOCONSOLE} -disablewallet=${BTC_DISABLEWALLET} -rpcbind=${BTC_RPCBIND} -rpcallowip=${BTC_RPCALLOWIP} diff --git a/devenv/docker-compose.yml b/devenv/docker-compose.yml index 61d96ff1..1a028551 100644 --- a/devenv/docker-compose.yml +++ b/devenv/docker-compose.yml @@ -3,16 +3,15 @@ version: '3.2' services: bitcoin: image: bitcoin:latest - container_name: bitcoin stop_grace_period: 5s build: context: ./bitcoin/docker args: VERSION: '25.0' ports: - - 18444:18444 - - 18443:18443 - - 18433:18433 + - 18444 + - 18443 + - 18433 environment: - 'BTC_NETWORK=regtest' - 'BTC_DISABLEWALLET=0' @@ -23,29 +22,26 @@ services: - 'BTC_RPCUSER=devnet' postgres: image: postgres:15-alpine - container_name: postgres stop_grace_period: 5s ports: - - 5432:5432 + - 5432 environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres mongodb: image: mongo:6.0 - container_name: mongodb stop_grace_period: 5s ports: - - 27017:27017 + - 27017 environment: MONGO_INITDB_ROOT_USERNAME: devnet MONGO_INITDB_ROOT_PASSWORD: devnet MONGO_INITDB_DATABASE: devnet mempool-db: image: mariadb:10.5.21 - container_name: mempool-db stop_grace_period: 5s ports: - - 3306:3306 + - 3306 environment: MYSQL_DATABASE: "mempool" MYSQL_USER: "mempool" @@ -53,18 +49,16 @@ services: MYSQL_ROOT_PASSWORD: "admin" miner: image: miner:latest - container_name: miner stop_grace_period: 5s build: context: ./miner/docker depends_on: - bitcoin environment: - INIT_BTC_BLOCKS: 200 - BTC_BLOCK_GEN_TIME: 10 + INIT_BTC_BLOCKS: 101 + BTC_BLOCK_GEN_TIME: 5 stacks: image: stacks:latest - container_name: stacks stop_grace_period: 5s build: context: ./stacks/docker @@ -73,8 +67,8 @@ services: GIT_URI: https://github.com/stacks-network/stacks-blockchain.git GIT_BRANCH: develop ports: - - 20444:20444 - - 20443:20443 + - 20444 + - 20443 depends_on: - bitcoin - miner @@ -83,7 +77,6 @@ services: - STACKS_LOG_JSON=0 stacks-api: image: stacks-api:latest - container_name: stacks-api stop_grace_period: 5s build: context: ./stacks-api/docker @@ -91,8 +84,8 @@ services: GIT_URI: 'https://github.com/hirosystems/stacks-blockchain-api.git' GIT_BRANCH: 'v7.3.0' ports: - - 3999:3999 - - 3700:3700 + - 3999 + - 3700 depends_on: - postgres - stacks @@ -115,7 +108,6 @@ services: - API_DOCS_URL=http://localhost:3999/doc stacks-explorer: image: stacks-explorer - container_name: stacks-explorer stop_grace_period: 5s build: context: ./stacks-explorer/docker @@ -124,7 +116,7 @@ services: GIT_URI: https://github.com/hirosystems/explorer.git GIT_BRANCH: v1.119.0 ports: - - 3020:3000 + - 3000 depends_on: - bitcoin - stacks @@ -134,13 +126,12 @@ services: - NEXT_PUBLIC_MAINNET_API_SERVER=http://127.0.0.1:3999 electrs: image: electrs:latest - container_name: electrs stop_grace_period: 5s build: context: ./electrs/docker ports: - - 60401:60401 - - 3002:3002 + - 60401 + - 3002 depends_on: - bitcoin - miner @@ -148,7 +139,6 @@ services: RUST_BACKTRACE: 1 sbtc: image: sbtc:latest - container_name: sbtc stop_grace_period: 5s restart: on-failure build: @@ -167,7 +157,6 @@ services: - $PWD/sbtc/docker/config.json:/romeo/config.json sbtc-bridge-api: image: sbtc-bridge-api:latest - container_name: sbtc-bridge-api stop_grace_period: 5s build: context: ./sbtc-bridge-api/docker @@ -185,7 +174,7 @@ services: - mongodb - sbtc ports: - - 3010:3010 + - 3010 environment: NODE_ENV: dev btcNode: bitcoin:18443 @@ -202,7 +191,6 @@ services: mongoPwd: devnet sbtc-bridge-web: image: sbtc-bridge-web:latest - container_name: sbtc-bridge-web stop_grace_period: 5s build: context: ./sbtc-bridge-web/docker @@ -216,10 +204,9 @@ services: - sbtc - sbtc-bridge-api ports: - - 8080:8080 + - 8080 mempool-web: image: mempool/frontend:latest - container_name: mempool-web stop_grace_period: 5s depends_on: - mempool-api @@ -227,14 +214,13 @@ services: user: "1000:1000" restart: on-failure ports: - - 8083:8083 + - 8083 environment: FRONTEND_HTTP_PORT: "8083" BACKEND_MAINNET_HTTP_HOST: "mempool-api" command: "./wait-for mempool-db:3306 --timeout=720 -- nginx -g 'daemon off;'" mempool-api: image: mempool/backend:latest - container_name: mempool-api stop_grace_period: 5s depends_on: - electrs @@ -242,7 +228,7 @@ services: user: "1000:1000" restart: on-failure ports: - - 8999:8999 + - 8999 environment: # Connect to electrs host MEMPOOL_BACKEND: "electrum" diff --git a/devenv/integration/README.md b/devenv/integration/README.md new file mode 100644 index 00000000..4e4743ed --- /dev/null +++ b/devenv/integration/README.md @@ -0,0 +1,133 @@ +## Containerized integration testing + +Hi! Congratulations on making it this far. By now, you should know how to launch +a full node for local development using devnev. To take a step further, these +are the steps you need to follow to run and implement integration tests using +devenv. + +## Quickstart + +1. Build the testbed image with the source and binaries to execute the + integration tests. +2. Run the testing script. + +```bash +> devenv$ cd integration +> devenv/integration$ ./bin/build +> cd - +> devenv$ ./integration/bin/test +``` + +You will see lines like the one below if your tests are completed successfully. + +``` +Runner: 71c1beca7261e2c1b706fa0e9eeb3823ad56977c4846c89b25a315a48a1bbda5 exited with err_no: 0 +Runner: fb2da5362115b5ae37874d39831bbf249b2928c58296dfb23af94b7083d8455b exited with err_no: 0 +``` + +Use the container id to inspect the runner for more details. + +``` +> devenv$ ./logs.sh 71c1beca7261e2c1b706fa0e9eeb3823ad56977c4846c89b25a315a48a1bbda5 +``` + +The script will abort the moment a container fails. The script will print the +logs from the first failed container. You must stop the nodes with +`docker stop $(docker ps -q)`. You can also rerun `test` to 'down' and 'up' any +dangling container and re-execute tests once you have fixed and rebuilt the +testbed image. + +## QuickStart + +1. Build the testbed image that has the source and binaries to execute the integration tests. +2. Run the testing script. + +```bash +> devenv$ cd integration +> devenv/integration$ ./bin/build +> cd - +> devenv$ ./integration/bin/test +``` + +You will see lines like below if your tests completed succesfully. + +``` +Runner: 71c1beca7261e2c1b706fa0e9eeb3823ad56977c4846c89b25a315a48a1bbda5 exited with err_no: 0 +Runner: fb2da5362115b5ae37874d39831bbf249b2928c58296dfb23af94b7083d8455b exited with err_no: 0 +``` + +Use the container id to inspect the runner for more details. + +``` +> devenv$ ./logs.sh 71c1beca7261e2c1b706fa0e9eeb3823ad56977c4846c89b25a315a48a1bbda5 +``` + +the script will abort the moment a container fails. The script will print the +logs from the first failed container. You will need to stop the nodes yourself +with `docker stop $(docker ps -qa)`. You can also run `test` again to down and +up again any dangling container and reexecute tests once you have fixed and +rebuilt the testbed image. + +### Running integration tests. + +Start at devenv, `pushd` integration. You will find scripts in the `bin` folder +in this directory. The ones you will be using are `build` and `test`. `Test` is +how you are expected to run the suite. Run `bin/build` and `popd,` back in +devenv, and run `integration/bin/test`. + +### Adding grouping filters + +In /devenv/integration/test, there is a filter array that determines how many +nodes will be spun and what tests will run in parallel inside the node. + +```bash +filters=("package(romeo)" "test(deposit_parse)" "test(deposit_output)") +``` + +Use Nextest's DSL to group up your integration tests. Add new filters as you see +fit. + +### Adding node readiness checks. + +In `devenv/integration/docker/entrypoint`, you can add checks to wait until a +node is ready to take tests. + +In this snip, we wait until the stacks api is responsive and the burchain block +height is 205. + +```bash +STACKS=$PROJECT_NAME-stacks-1 +API_URL=http://$STACKS:20443/v2/info + +# it makes sure the node is ready before proceeding +# stacks node get info +echo "Waiting on Stacks API" +while ! curl -s $API_URL >/dev/null; do + sleep 1 +done + +DEV_READY_HEIGHT=205 + +# bitcoind get info +echo "Waiting on burn block height $DEV_READY_HEIGHT" +while [ "$(curl -s $API_URL | jq '.burn_block_height')" -lt $DEV_READY_HEIGHT ]; do + sleep 2 +done +``` + +### Troubleshooting. + +- If a fresh network fails to be created or you spot a line like the one below, you need + to stop all containers and prune the network. + +``` +! Network test_deposit__default Resource is still in use 0.0s +``` + +``` +> docker stop $(docker ps -q) +> docker network prune +``` + +- The order of magnitude for the tests should be in **minutes**. It took 4mins + in my system last time I checked. diff --git a/devenv/integration/bin/build b/devenv/integration/bin/build new file mode 100755 index 00000000..3de2abf8 --- /dev/null +++ b/devenv/integration/bin/build @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +docker compose -f docker-compose.yml build diff --git a/devenv/integration/bin/down b/devenv/integration/bin/down new file mode 100755 index 00000000..9c921978 --- /dev/null +++ b/devenv/integration/bin/down @@ -0,0 +1,8 @@ +#!/usr/bin/env sh + +if [ -z "$2" ]; then + echo "Use: down " + exit 1 +fi + +docker compose -f $1 -p $2 down diff --git a/devenv/integration/bin/test b/devenv/integration/bin/test new file mode 100755 index 00000000..819b7214 --- /dev/null +++ b/devenv/integration/bin/test @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -ueo >/dev/null + +ENTRYPOINT_PATH="./integration/docker/entrypoint" +CONFIG_PATH="./sbtc/docker/config.json" + +project_name() { + local input="$@" + local filter="${input//[![:alnum:]]/_}" + echo "$filter" +} + +filters=("test(deposit) or test(withdrawal)") +filter_union="" +ids=() +projects=() + +for filter in "${filters[@]}"; do + project=$(project_name $filter) + echo Working project: $project + projects+=($project) + + ./integration/bin/down docker-compose.yml $project + ./integration/bin/up docker-compose.yml $project + + network=${project}_default + id=$(docker run -td --network $network -v $ENTRYPOINT_PATH:/usr/local/bin/entrypoint -v $CONFIG_PATH:"/romeo/config.json" -e PROJECT_NAME=$project testbed "$filter") + ids+=($id) + echo with container id: $id + + if [ -z "$filter_union" ]; then + filter_union=$filter + else + filter_union="$filter_union | $filter" + fi + +done + +project="remainder--" +echo Working project: $project +projects+=($project) + +./integration/bin/down docker-compose.yml $project +./integration/bin/up docker-compose.yml $project + +network=${project}_default +id=($(docker run -td --network $network -v $ENTRYPOINT_PATH:/usr/local/bin/entrypoint -e PROJECT_NAME=$project testbed "not ($filter_union)")) +ids+=($id) +echo with container id: $id + +for id in ${ids[@]}; do + exit_code=$(docker wait $id) + echo Runner: $id exited with err_no: $exit_code + if [ $exit_code -ne 0 ]; then + docker logs -f $id + exit $exit_code + fi +done + +for project in ${projects[@]}; do + ./integration/bin/down docker-compose.yml $project +done diff --git a/devenv/integration/bin/up b/devenv/integration/bin/up new file mode 100755 index 00000000..d3a37d33 --- /dev/null +++ b/devenv/integration/bin/up @@ -0,0 +1,8 @@ +#!/usr/bin/env sh + +if [ -z "$2" ]; then + echo "Use: up " + exit 1 +fi + +docker compose -f $1 -p $2 up -d --remove-orphans diff --git a/devenv/integration/docker-compose.yml b/devenv/integration/docker-compose.yml new file mode 100644 index 00000000..9bc2f9e0 --- /dev/null +++ b/devenv/integration/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.2' + +networks: + devenv_default: + external: true + +services: + testbed: + image: testbed:latest + build: + dockerfile: devenv/integration/docker/Dockerfile + context: ./../../ + networks: + - devenv_default diff --git a/devenv/integration/docker/Dockerfile b/devenv/integration/docker/Dockerfile new file mode 100644 index 00000000..8deafe62 --- /dev/null +++ b/devenv/integration/docker/Dockerfile @@ -0,0 +1,41 @@ +FROM rust:bookworm as clarinet + +RUN apt update && apt install -y pkg-config libssl-dev clang && \ + rm -rf /var/lib/apt/lists/* + +RUN rustup update 1.71 && rustup default 1.71 +RUN cargo install clarinet-cli --bin clarinet --branch develop --locked --git https://github.com/hirosystems/clarinet.git + +FROM rust:bookworm as romeo + +RUN apt update && \ + apt install -y libssl-dev libclang-dev libsecp256k1-dev && \ + rm -rf /var/lib/apt/lists/* + +RUN rustup component add rustfmt + +ENV RUSTFLAGS "-C target-feature=-crt-static" + +RUN cargo install cargo-nextest --locked + +COPY . . + +RUN cargo nextest archive --archive-file integration-tests.tar.zst + +FROM rust:bookworm as runtime + +RUN apt update && \ + apt install -y openssl ca-certificates + +COPY --from=clarinet /usr/local/cargo/bin/clarinet /usr/bin + +RUN apt install -y curl jq && \ + rm -rf /var/lib/apt/lists/* + +RUN cargo install cargo-nextest --locked + +COPY --from=romeo integration-tests.tar.zst . + +COPY . . + +ENTRYPOINT [ "entrypoint" ] diff --git a/devenv/integration/docker/entrypoint b/devenv/integration/docker/entrypoint new file mode 100755 index 00000000..c4a58cd6 --- /dev/null +++ b/devenv/integration/docker/entrypoint @@ -0,0 +1,31 @@ +#!/usr/bin/env sh +set -ueo >/dev/null + +API_URL=http://stacks:20443/v2/info + +# --- make sure the node is ready before proceeding + +# stacks node get info +echo "Waiting on Stacks API $API_URL" +while ! curl -s $API_URL >/dev/null; do + sleep 1 +done + +#stacks ready to take contracts +STACKS_HEIGHT=1 +echo "Waiting on Stacks height $STACKS_HEIGHT" +while [ "$(curl -s $API_URL | jq '.stacks_tip_height')" -lt $STACKS_HEIGHT ]; do + sleep 2 +done + +# any other service checks + +# push contracts +cd romeo/asset-contract +sed -i "s/localhost:20443/stacks:20443/" deployments/default.devnet-plan.yaml +sed -i "s/localhost:18443/bitcoin:18443/" deployments/default.devnet-plan.yaml +clarinet deployments apply --no-dashboard -d -p deployments/default.devnet-plan.yaml +cd - + +echo with filter: "'$@'" +cargo nextest run -E "$@ and kind(test)" --archive-file integration-tests.tar.zst --nocapture diff --git a/devenv/sbtc/docker/Dockerfile b/devenv/sbtc/docker/Dockerfile index 81449b6c..c6f1edce 100644 --- a/devenv/sbtc/docker/Dockerfile +++ b/devenv/sbtc/docker/Dockerfile @@ -28,4 +28,4 @@ ADD devenv/sbtc/docker/entrypoint /usr/local/bin RUN chmod a+x /usr/local/bin/entrypoint ADD romeo/asset-contract /asset-contract -ENTRYPOINT ["entrypoint"] \ No newline at end of file +ENTRYPOINT ["entrypoint"] diff --git a/romeo/Cargo.toml b/romeo/Cargo.toml index 58483c26..1102d57b 100644 --- a/romeo/Cargo.toml +++ b/romeo/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" anyhow.workspace = true backoff = { workspace = true, features = ["tokio"] } bdk = { workspace = true, features = ["rpc", "esplora", "use-esplora-async"] } -blockstack-core = { git = "https://github.com/stacks-network/stacks-blockchain/", branch = "master" } +blockstack-core.workspace = true clap = { workspace = true, features = ["derive"] } derivative = { workspace = true } futures.workspace = true @@ -23,3 +23,8 @@ tracing-subscriber.workspace = true tracing.workspace = true url.workspace = true rs_merkle.workspace = true + +[dev-dependencies] +reqwest = { workspace = true, features = ["json", "blocking"] } +sbtc-cli = { path = "../sbtc-cli" } +stacks-core = { path = "../stacks-core", features = ["test-utils"] } diff --git a/romeo/examples/deposit_withdrawal.rs b/romeo/examples/deposit_withdrawal.rs new file mode 100644 index 00000000..5a1d3076 --- /dev/null +++ b/romeo/examples/deposit_withdrawal.rs @@ -0,0 +1,190 @@ +use std::{io::Cursor, time::Duration}; + +use bdk::{ + bitcoin::{psbt::serialize::Serialize, PrivateKey}, + blockchain::{ + ConfigurableBlockchain, ElectrumBlockchain, ElectrumBlockchainConfig, + }, + database::MemoryDatabase, + template::P2Wpkh, + SyncOptions, Wallet, +}; +use blockstack_lib::{ + codec::StacksMessageCodec, + util::hash::hex_bytes, + vm::{ + types::{QualifiedContractIdentifier, StandardPrincipalData}, + Value, + }, +}; +use romeo::{config::Config, stacks_client::StacksClient}; +use sbtc_cli::commands::{ + broadcast::{broadcast_tx, BroadcastArgs}, + deposit::{build_deposit_tx, DepositArgs}, + withdraw::{build_withdrawal_tx, WithdrawalArgs}, +}; +use stacks_core::address::StacksAddress; +use tokio::time::sleep; +use url::Url; + +/// Wait until all your services are ready before running. +/// Don't forget to fund W0 (deployer) and W1 (recipient). +#[tokio::main] +async fn main() { + let mut config = + Config::from_path("./devenv/sbtc/docker/config.json").unwrap(); + config.stacks_node_url = "http://localhost:3999".parse().unwrap(); + config.bitcoin_node_url = "http://localhost:18443".parse().unwrap(); + config.electrum_node_url = "tcp://localhost:60401".parse().unwrap(); + + let blockchain = + ElectrumBlockchain::from_config(&ElectrumBlockchainConfig { + url: config.electrum_node_url.clone().into(), + socks5: None, + retry: 3, + timeout: Some(10), + stop_gap: 10, + validate_domain: false, + }) + .unwrap(); + + let recipient_p2wpkh_wif = + "cNcXK2r8bNdWJQymtAW8tGS7QHNtFFvG5CdXqhhT752u29WspXRM"; + + // W1 + let wallet = { + let private_key = PrivateKey::from_wif(recipient_p2wpkh_wif).unwrap(); + + Wallet::new( + P2Wpkh(private_key), + Some(P2Wpkh(private_key)), + bdk::bitcoin::Network::Regtest, + MemoryDatabase::default(), + ) + .unwrap() + }; + + loop { + wallet.sync(&blockchain, SyncOptions::default()).unwrap(); + let balance = wallet.get_balance().unwrap().confirmed; + println!("recipient's btc: {balance}"); + if balance != 0 { + break; + } + sleep(Duration::from_secs(1)).await; + } + + let recipient = "ST2ST2H80NP5C9SPR4ENJ1Z9CDM9PKAJVPYWPQZ50"; + let amount = 1000; + + // deposit + { + let electrum_url = + Url::parse(config.electrum_node_url.as_str()).unwrap(); + let tx = { + let args = DepositArgs { + node_url: electrum_url.clone(), + wif: recipient_p2wpkh_wif.into(), + network: bdk::bitcoin::Network::Regtest, + recipient:recipient.into(), + amount, + sbtc_wallet: "bcrt1pte5zmd7qzj4hdu45lh9mmdm0nwq3z35pwnxmzkwld6y0a8g83nnqhj6vc0".into(), + }; + + build_deposit_tx(&args).unwrap() + }; + + broadcast_tx(&BroadcastArgs { + node_url: electrum_url, + tx: hex::encode(tx.serialize()), + }) + .unwrap(); + } + + let deployer = "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM"; + let deployer_address = StacksAddress::transmute_stacks_address(deployer); + let recipient_address = StacksAddress::transmute_stacks_address(recipient); + + let stacks_client = + StacksClient::new(config.clone(), reqwest::Client::new()); + + println!("Waiting on sBTC mint"); + // request token balance from the asset contract. + while { + let res: serde_json::Value = stacks_client + .call_read_only_fn( + QualifiedContractIdentifier::new( + StandardPrincipalData::from(deployer_address), + config.contract_name.clone(), + ), + "get-balance", + recipient_address.to_string().as_str(), + vec![StandardPrincipalData::from(recipient_address).into()], + ) + .await + .unwrap(); + + assert!(res["okay"].as_bool().unwrap()); + let bytes = + hex_bytes(res["result"].as_str().unwrap().trim_start_matches("0x")) + .unwrap(); + + let mut cursor = Cursor::new(&bytes); + Value::consensus_deserialize(&mut cursor) + .unwrap() + .expect_result_ok() + .expect_u128() + } < amount as u128 + { + sleep(Duration::from_secs(2)).await; + } + + let fee = 331; + // withdraw + let args = WithdrawalArgs { + node_url: config.electrum_node_url.clone(), + network: bdk::bitcoin::Network::Regtest, + // p2wpkh + wif: "cNcXK2r8bNdWJQymtAW8tGS7QHNtFFvG5CdXqhhT752u29WspXRM".into(), + // Stacks + drawee_wif: "cR9hENRFiuHzKpj9B3QCTBrt19c5ZCJKHJwYcqj5dfB6aKyf6ndm" + .into(), + payee_address: "bcrt1q3zl64vadtuh3vnsuhdgv6pm93n82ye8q6cr4ch".into(), + amount, + fulfillment_fee: fee, + sbtc_wallet: + "bcrt1pte5zmd7qzj4hdu45lh9mmdm0nwq3z35pwnxmzkwld6y0a8g83nnqhj6vc0" + .into(), + }; + + let tx = build_withdrawal_tx(&args).unwrap(); + + let balance = loop { + wallet.sync(&blockchain, SyncOptions::default()).unwrap(); + let balance = wallet.get_balance().unwrap().confirmed; + if 0 < balance { + println!("recipient's btc: {balance}"); + break balance; + } + sleep(Duration::from_secs(1)).await + }; + + broadcast_tx(&BroadcastArgs { + node_url: config.electrum_node_url.clone(), + tx: hex::encode(tx.serialize()), + }) + .unwrap(); + + println!("Waiting on fulfillment"); + loop { + wallet.sync(&blockchain, SyncOptions::default()).unwrap(); + let current = wallet.get_balance().unwrap().confirmed; + // will fail if tx_fees is not an upper bound for real fees. + let tx_fees = 400; + println!("recipient's btc: {balance}"); + if balance.saturating_sub(fee + tx_fees) < current { + break; + } + sleep(Duration::from_secs(2)).await; + } +} diff --git a/romeo/examples/fund.rs b/romeo/examples/fund.rs new file mode 100644 index 00000000..0a9fec55 --- /dev/null +++ b/romeo/examples/fund.rs @@ -0,0 +1,31 @@ +use std::str::FromStr; + +use bdk::{ + bitcoin::{Address, BlockHash}, + bitcoincore_rpc::{Auth, Client, RpcApi}, +}; + +fn mine_blocks(client: &Client, blocks: u64, address: &str) -> Vec { + client + .generate_to_address(blocks, &Address::from_str(address).unwrap()) + .unwrap() +} + +fn main() { + let client = Client::new( + "http://localhost:18443", + Auth::UserPass("devnet".into(), "devnet".into()), + ) + .unwrap(); + + // p2wpkh W0 + let address_0 = "bcrt1q3tj2fr9scwmcw3rq5m6jslva65f2rqjxfrjz47"; + let block_hashes = mine_blocks(&client, 10, address_0); + println!("blocks mined: {block_hashes:#?}"); + let address_1 = "bcrt1q3zl64vadtuh3vnsuhdgv6pm93n82ye8q6cr4ch"; + let block_hashes = mine_blocks(&client, 10, address_1); + println!("blocks mined: {block_hashes:#?}"); + let address_0 = "bcrt1q3tj2fr9scwmcw3rq5m6jslva65f2rqjxfrjz47"; + let block_hashes = mine_blocks(&client, 101, address_0); + println!("padding blocks mined: {block_hashes:#?}"); +} diff --git a/romeo/src/stacks_client.rs b/romeo/src/stacks_client.rs index a0820be0..a20133ba 100644 --- a/romeo/src/stacks_client.rs +++ b/romeo/src/stacks_client.rs @@ -12,6 +12,7 @@ use blockstack_lib::{ codec::StacksMessageCodec, core::CHAIN_ID_TESTNET, types::chainstate::StacksPrivateKey, + util::hash::bytes_to_hex, vm::{ types::{QualifiedContractIdentifier, StandardPrincipalData}, ContractName, @@ -21,7 +22,7 @@ use futures::Future; use rand::{distributions::Alphanumeric, thread_rng, Rng}; use reqwest::{Request, RequestBuilder, Response, StatusCode}; use serde::de::DeserializeOwned; -use serde_json::Value; +use serde_json::{json, Value}; use stacks_core::{codec::Codec, uint::Uint256}; use tokio::{ sync::{Mutex, MutexGuard}, @@ -463,6 +464,46 @@ impl StacksClient { } } +impl StacksClient { + /// Call read-only functions from smart contracts + pub async fn call_read_only_fn( + &self, + contract: QualifiedContractIdentifier, + method: &str, + sender: &str, + args: Vec, + ) -> anyhow::Result + where + R: DeserializeOwned, + { + Ok(self + .http_client + .post( + self.config + .stacks_node_url + .join( + format!( + "/v2/contracts/call-read/{}/{}/{}", + contract.issuer, contract.name, method + ) + .as_str(), + ) + .unwrap(), + ) + .json(&json!({ + "sender": sender, + "arguments": args + .iter() + .map(|a| bytes_to_hex(&a.serialize_to_vec())) + .collect::>() } + )) + .send() + .await? + .json() + .await?) + } +} + #[derive(serde::Deserialize)] struct NonceInfo { possible_next_nonce: u64, diff --git a/romeo/src/system.rs b/romeo/src/system.rs index 30d27b25..b20fc17e 100644 --- a/romeo/src/system.rs +++ b/romeo/src/system.rs @@ -227,8 +227,7 @@ async fn update_contract_public_key( .bitcoin_credentials .public_key_p2tr() .serialize() - .try_into() - .unwrap(), + .into(), ) .expect("Cannot convert public key into a Clarity Value")]; diff --git a/romeo/tests/integration.rs b/romeo/tests/integration.rs new file mode 100644 index 00000000..14f00389 --- /dev/null +++ b/romeo/tests/integration.rs @@ -0,0 +1 @@ +mod tests; diff --git a/romeo/tests/tests/bitcoin_client.rs b/romeo/tests/tests/bitcoin_client.rs new file mode 100644 index 00000000..8614aacd --- /dev/null +++ b/romeo/tests/tests/bitcoin_client.rs @@ -0,0 +1,96 @@ +use std::{io::Cursor, str::FromStr, time::Duration}; + +use bdk::{ + bitcoin::{hash_types::Txid, Address, BlockHash}, + bitcoincore_rpc::{Auth, Client as BClient, RpcApi}, +}; +use blockstack_lib::{ + codec::StacksMessageCodec, + types::chainstate::StacksAddress, + util::hash::hex_bytes, + vm::{ + types::{QualifiedContractIdentifier, StandardPrincipalData}, + ContractName, Value, + }, +}; +use romeo::stacks_client::StacksClient; +use tokio::time::sleep; +use url::Url; + +/// devenv's service url +pub fn bitcoin_url() -> Url { + Url::parse("http://bitcoin:18443").unwrap() +} + +/// devenv's service url +pub fn electrs_url() -> Url { + Url::parse("tcp://electrs:60401").unwrap() +} + +pub fn client_new(url: &str, user: &str, pass: &str) -> BClient { + BClient::new(url, Auth::UserPass(user.into(), pass.into())).unwrap() +} + +pub fn mine_blocks( + client: &BClient, + blocks: u64, + address: &str, +) -> Vec { + client + .generate_to_address(blocks, &Address::from_str(address).unwrap()) + .unwrap() +} + +pub async fn wait_for_tx_confirmation( + b_client: &BClient, + txid: &Txid, + confirmations: i32, +) { + loop { + match b_client.get_transaction(txid, None) { + Ok(tx) if tx.info.confirmations >= confirmations => { + break; + } + Ok(ok) => { + println!("Waiting confirmation on {txid}:{ok:?}") + } + Err(e) => { + println!("Waiting confirmation on {txid}:{e:?}") + } + } + + sleep(Duration::from_secs(1)).await; + } +} + +pub async fn sbtc_balance( + stacks_client: &StacksClient, + deployer_address: StacksAddress, + recipient_address: StacksAddress, + contract_name: ContractName, +) -> u128 { + let res: serde_json::Value = stacks_client + .call_read_only_fn( + QualifiedContractIdentifier::new( + StandardPrincipalData::from(deployer_address), + contract_name, + ), + "get-balance", + recipient_address.to_string().as_str(), + vec![StandardPrincipalData::from(recipient_address).into()], + ) + .await + .unwrap(); + + assert!(res["okay"].as_bool().unwrap()); + // request token balance from the asset contract. + let bytes = + hex_bytes(res["result"].as_str().unwrap().trim_start_matches("0x")) + .unwrap(); + + let mut cursor = Cursor::new(&bytes); + Value::consensus_deserialize(&mut cursor) + .unwrap() + .expect_result_ok() + .expect_u128() +} diff --git a/romeo/tests/tests/deposit.rs b/romeo/tests/tests/deposit.rs new file mode 100644 index 00000000..242cf056 --- /dev/null +++ b/romeo/tests/tests/deposit.rs @@ -0,0 +1,151 @@ +use std::{str::FromStr, time::Duration}; + +use anyhow::Result; +use bdk::{ + bitcoin::{psbt::serialize::Serialize, Address, PrivateKey}, + bitcoincore_rpc::RpcApi, + blockchain::{ + ConfigurableBlockchain, ElectrumBlockchain, ElectrumBlockchainConfig, + }, + database::MemoryDatabase, + template::P2Wpkh, + SyncOptions, Wallet, +}; +use romeo::{config::Config, stacks_client::StacksClient}; +use sbtc_cli::commands::{ + broadcast::{broadcast_tx, BroadcastArgs}, + deposit::{build_deposit_tx, DepositArgs}, +}; +use stacks_core::address::StacksAddress; +use tokio::time::sleep; + +use super::{ + bitcoin_client::{ + bitcoin_url, client_new, electrs_url, mine_blocks, sbtc_balance, + wait_for_tx_confirmation, + }, + KeyType::*, + WALLETS, +}; + +#[tokio::test] +/// preceeds withdrawal +async fn broadcast_deposit() -> Result<()> { + let b_client = client_new(bitcoin_url().as_str(), "devnet", "devnet"); + + b_client + .import_address( + &Address::from_str(WALLETS[1][P2wpkh].address).unwrap(), + None, + Some(false), + ) + .unwrap(); + + { + mine_blocks(&b_client, 1, WALLETS[0][P2wpkh].address); + mine_blocks(&b_client, 1, WALLETS[1][P2wpkh].address); + // pads blocks to get rewards. + mine_blocks(&b_client, 100, WALLETS[0][P2wpkh].address); + }; + + let electrum_url = electrs_url(); + + { + let blockchain = + ElectrumBlockchain::from_config(&ElectrumBlockchainConfig { + url: electrum_url.clone().into(), + socks5: None, + retry: 3, + timeout: Some(10), + stop_gap: 10, + validate_domain: false, + }) + .unwrap(); + + let private_key = PrivateKey::from_wif(WALLETS[1][P2wpkh].wif)?; + + let wallet = Wallet::new( + P2Wpkh(private_key), + Some(P2Wpkh(private_key)), + bdk::bitcoin::Network::Regtest, + MemoryDatabase::default(), + ) + .unwrap(); + + loop { + wallet.sync(&blockchain, SyncOptions::default()).unwrap(); + let balance = wallet.get_balance().unwrap(); + if balance.confirmed != 0 { + break; + } + sleep(Duration::from_millis(1_000)).await; + } + } + + let amount = 10_000; + let deployer_stacks_address = WALLETS[0][Stacks].address; + let recipient_stacks_address = WALLETS[1][Stacks].address; + + let tx = { + let args = DepositArgs { + node_url: electrum_url.clone(), + wif: WALLETS[1][P2wpkh].wif.into(), + network: bdk::bitcoin::Network::Regtest, + recipient: recipient_stacks_address.into(), + amount, + sbtc_wallet: WALLETS[0][P2tr].address.into(), + }; + + build_deposit_tx(&args).unwrap() + }; + + let config = Config::from_path("config.json").unwrap(); + + // make sure config urls match devenv. + let stacks_client = + StacksClient::new(config.clone(), reqwest::Client::new()); + + let deployer_address = + StacksAddress::transmute_stacks_address(deployer_stacks_address); + let recipient_address = + StacksAddress::transmute_stacks_address(recipient_stacks_address); + + // prior balance + assert_eq!( + sbtc_balance( + &stacks_client, + deployer_address, + recipient_address, + config.contract_name.clone() + ) + .await, + 0 + ); + + // Sign, send and wait for confirmation. + { + broadcast_tx(&BroadcastArgs { + node_url: electrum_url, + tx: hex::encode(tx.serialize()), + }) + .unwrap(); + + let txid = tx.txid(); + + wait_for_tx_confirmation(&b_client, &txid, 1).await; + } + + // assert on new sbtc token balance + while sbtc_balance( + &stacks_client, + deployer_address, + recipient_address, + config.contract_name.clone(), + ) + .await != amount as u128 + { + sleep(Duration::from_secs(2)).await + } + + Ok(()) +} diff --git a/romeo/tests/tests/mod.rs b/romeo/tests/tests/mod.rs new file mode 100644 index 00000000..e267debd --- /dev/null +++ b/romeo/tests/tests/mod.rs @@ -0,0 +1,112 @@ +pub mod bitcoin_client; +pub mod deposit; +pub mod stacks_client; +pub mod withdrawal; + +use std::ops::Index; + +struct KeyRing { + address: &'static str, + #[allow(unused)] + private_key: &'static str, + #[allow(unused)] + public_key: &'static str, + wif: &'static str, +} + +#[repr(usize)] +pub(crate) enum KeyType { + #[allow(unused)] + P2pkh, + P2tr, + P2wpkh, + Stacks, +} + +impl Index for [KeyRing] { + type Output = KeyRing; + + fn index(&self, index: KeyType) -> &Self::Output { + &self[index as usize] + } +} + +type FullRing = [KeyRing; 4]; + +const WALLETS: [FullRing; 3] = [[ + KeyRing { + address: "n4dN5bVeriVW9gKZMfNqHn21aJkwTM8QPH", + private_key: "a38cbb2ca77786b9d37fd0feb34df2e423130ec74d0189736bf52561562c9565", + public_key: "03bcb048737cc2f239db2b3db6eae00263861bfbe5b2577e573e3c32f61a46ac8c", + wif: "cT4cy7eAKPjhaZ3h72GdpbrtrFzsUQShZEcM5eNCiHrfuzmoXvBt", + }, + KeyRing { + address: "bcrt1pte5zmd7qzj4hdu45lh9mmdm0nwq3z35pwnxmzkwld6y0a8g83nnqhj6vc0", + private_key: "6596d84eef5b73430712dde88fbf6a1d96f97f5f241ab1bf247d04bc241dd28d", + public_key: "034a45bd09cc815da165b8987a7263a2c4111b79951562fc5c0989e9cdf5ceded2", + wif: "cQzBGXC4YACb61oCwxDK9F1a8nxCjUiBZ5rBUaUJAeQvTytUBBFi", + }, + KeyRing { + address: "bcrt1q3tj2fr9scwmcw3rq5m6jslva65f2rqjxfrjz47", + private_key: "bea4ecfec5cfa1e965ee1b3465ca4deff4f04b36a1fb5286a07660d5158789fb", + public_key: "03ab37f5b606931d7828855affe75199d952bc6174b4a23861b7ac94132210508c", + wif: "cTyHitzs3VRnNxrpwxo3fXTTe569wHNUs57tQM7Z1FrzUDNB5mqm", + }, + KeyRing { + address: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM", + private_key: "753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a6", + public_key: "0390a5cac7c33fda49f70bc1b0866fa0ba7a9440d9de647fecb8132ceb76a94dfa", + wif: "cRWawjcDj2J28jczAtjJGKs1pzFxM6V6tJHNZp3WrYoLhr2PLMVB", + }, +],[ + KeyRing { + address: "mvTQYAcGa17CTxWcJhRPXn6qecBQLSWuaJ", + private_key: "a1fc751f0bb64c01adb7c60dbe966e2bb9e262aa23bf41158e64c2142fc4fa78", + public_key: "030ede1203e7873388f81a7801df5714152c72273507a0fe0609e3f223fb6f56ae", + wif: "cT1agfN8XrWY1bSW5DGWpXUWYFjrmKWeJHyU79Kk66dPnMC6fw3L" + }, + KeyRing { + address: "bcrt1prm3lfhsgnnxe0def39n7xpa9etqrjfdxeqnar9xegqu4c044td0sfktyxq", + private_key: "73f560df660fb11c9aff8178971acd67e75ecb4e5683f5e9bdc52fb3c967c7a3", + public_key: "02aaed53527e3771645a050568a3cc9820361899c36f689cac15b57cc7885f3ca1", + wif: "cRU7KinnTuwaJ9N6WFDg2YdhtKcU5vxTSKFtgmT2gTtFvNfhnXx9" + }, + KeyRing { + address: "bcrt1q3zl64vadtuh3vnsuhdgv6pm93n82ye8q6cr4ch", + private_key: "1ec64b686cf94a4d8c741ed34db074b86d91c0971a38fe6e161b402489d7a74e", + public_key: "03969ff3e2bf7f2f73dc903cd11442032c8c7811d57d96ce327ee89c9edea63fa8", + wif: "cNcXK2r8bNdWJQymtAW8tGS7QHNtFFvG5CdXqhhT752u29WspXRM" + }, + KeyRing { + address: "ST2ST2H80NP5C9SPR4ENJ1Z9CDM9PKAJVPYWPQZ50", + private_key: "6a7c24ee77649c0cc314864596a6bd1addf3efb93bd63bcdb99be08437420847", + public_key: "038386f533650ff82714eeac9438faaa8a20ada5dd68a7eb8e00cf46cab5325a68", + wif: "cR9hENRFiuHzKpj9B3QCTBrt19c5ZCJKHJwYcqj5dfB6aKyf6ndm" + } + +],[ + KeyRing { + address: "n3cR74zFVWNnEusWTWKvDyuCemjT6zVv2y", + private_key: "9f1e7c24320af2b6c26b977e0eac0d19b69444b5a00b6a4ceca9849dcfa0e1d8", + public_key: "02d1b831b466e71161bfb91c7933483e9414f14435fddfdf37c7e41b78f657a880", + wif: "cSv1SKPZijZm3Kjip78csAiRq5JpQCCJLTumi9CCMqJzEDe2DNji" + }, + KeyRing{ + address: "bcrt1p3cq25gmqaltumf3l9d9e6qz836s3nu7vvjsnjvvkudaucly3h4fqq63tug", + private_key: "39657a54a708a1f2df728c40612aac7605c093daefb4552dceebfebe06aef1c8", + public_key: "02d3f0669085642a8cb94d574fd1cdc74ae0bc01b07c9572d4cd5f32b1e622d378", + wif: "cPWGmk3RD9FWqt3MJN78zGKf3AzpUXmbmvof4x9Gggh54DnFUByL" + }, + KeyRing{ + address: "bcrt1q266gk7s8efpwl0nasamcmc627tm37wnzxmgugt", + private_key: "66f8f1682915abb46f6a669ada600ade92e04739ce6e005fdde34d57ce64d40d", + public_key: "02a6510b8cf31689d9fd51c3237f0e81bf53201072bb9b16e34f86108566465aa6", + wif: "cR2sDUprEWkAQra7hY832dSjPhuc5eTbpJr8KnvBKBNtAJH1pvMJ" + }, + KeyRing { + address: "ST2Y2SFNVZBT8SSZ00XXKH930MCN0RFREB2GQG7CJ", + private_key: "6703304161a59dc3369c650ae97cca299df8bebb5638f12d4ff69778cba6ce3a", + public_key: "0388a0608e9268022ab38bedc8db10e562b6b6672e64dc30191a64f22b9a4a8d4d", + wif: "cR2wjBsDB1HVZghipz8Lxox2XgK47RURJHF694g8WqDzVd9oRv8u" + } +]]; diff --git a/romeo/tests/tests/stacks_client.rs b/romeo/tests/tests/stacks_client.rs new file mode 100644 index 00000000..cccc12a6 --- /dev/null +++ b/romeo/tests/tests/stacks_client.rs @@ -0,0 +1,28 @@ +use reqwest::blocking::Client; +use url::Url; + +/// devenv's service url +pub fn stacks_url() -> Url { + Url::parse("http://stacks:20443").unwrap() +} + +pub fn fetch_stacks_height(ctx: &Client) -> u64 { + let endpoint = stacks_url().join("v2/info").unwrap(); + + let response_json: serde_json::Value = + ctx.get(endpoint).send().unwrap().json().unwrap(); + + serde_json::from_value(response_json["stacks_tip_height"].clone()).unwrap() +} + +// Test fetch stacks info. +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn fetch_height() { + let ctx = Client::new(); + fetch_stacks_height(&ctx); + } +} diff --git a/romeo/tests/tests/withdrawal.rs b/romeo/tests/tests/withdrawal.rs new file mode 100644 index 00000000..8ece0e84 --- /dev/null +++ b/romeo/tests/tests/withdrawal.rs @@ -0,0 +1,140 @@ +use std::{str::FromStr, time::Duration}; + +use bdk::{ + bitcoin::{psbt::serialize::Serialize, Address, PrivateKey}, + bitcoincore_rpc::RpcApi, + blockchain::{ + ConfigurableBlockchain, ElectrumBlockchain, ElectrumBlockchainConfig, + }, + database::MemoryDatabase, + template::P2Wpkh, + SyncOptions, Wallet, +}; +use romeo::{config::Config, stacks_client::StacksClient}; +use sbtc_cli::commands::{ + broadcast::{broadcast_tx, BroadcastArgs}, + withdraw::{build_withdrawal_tx, WithdrawalArgs}, +}; +use stacks_core::address::StacksAddress; +use tokio::time::sleep; + +use super::{ + bitcoin_client::{ + bitcoin_url, client_new, electrs_url, sbtc_balance, + wait_for_tx_confirmation, + }, + KeyType::*, + WALLETS, +}; + +#[tokio::test] +/// Depends on deposit test. +async fn broadcast_withdrawal() { + // wait until stacks addr has some balance + + let deployer_address = + StacksAddress::transmute_stacks_address(WALLETS[0][Stacks].address); + let recipient_address = + StacksAddress::transmute_stacks_address(WALLETS[1][Stacks].address); + + let config = Config::from_path("config.json").unwrap(); + + let stacks_client = + StacksClient::new(config.clone(), reqwest::Client::new()); + + // sbtc credited + let amount = loop { + let amount = sbtc_balance( + &stacks_client, + deployer_address, + recipient_address, + config.contract_name.clone(), + ) + .await; + if amount != 0 { + break amount as u64; + } + sleep(Duration::from_secs(2)).await; + }; + + let b_client = client_new(bitcoin_url().as_str(), "devnet", "devnet"); + + b_client + .import_address( + &Address::from_str(WALLETS[1][P2wpkh].address).unwrap(), + None, + Some(false), + ) + .unwrap(); + + let args = WithdrawalArgs { + node_url: electrs_url(), + network: bdk::bitcoin::Network::Regtest, + wif: WALLETS[1][P2wpkh].wif.into(), + drawee_wif: WALLETS[1][Stacks].wif.into(), + payee_address: WALLETS[2][P2wpkh].address.into(), + amount, + fulfillment_fee: 2000, + sbtc_wallet: WALLETS[0][P2tr].address.into(), + }; + + let tx = build_withdrawal_tx(&args).unwrap(); + + broadcast_tx(&BroadcastArgs { + node_url: electrs_url(), + tx: hex::encode(tx.serialize()), + }) + .unwrap(); + + let txid = tx.txid(); + + wait_for_tx_confirmation(&b_client, &txid, 1).await; + + // sbtc debited + { + while { + sbtc_balance( + &stacks_client, + deployer_address, + recipient_address, + config.contract_name.clone(), + ) + } + .await != 0 + { + sleep(Duration::from_secs(2)).await; + } + } + + // btc credited + { + let blockchain = + ElectrumBlockchain::from_config(&ElectrumBlockchainConfig { + url: electrs_url().into(), + socks5: None, + retry: 3, + timeout: Some(10), + stop_gap: 10, + validate_domain: false, + }) + .unwrap(); + + let private_key = PrivateKey::from_wif(WALLETS[2][P2wpkh].wif).unwrap(); + + let wallet = Wallet::new( + P2Wpkh(private_key), + Some(P2Wpkh(private_key)), + bdk::bitcoin::Network::Regtest, + MemoryDatabase::default(), + ) + .unwrap(); + + while { + wallet.sync(&blockchain, SyncOptions::default()).unwrap(); + wallet.get_balance().unwrap().confirmed + } == 0 + { + sleep(Duration::from_secs(2)).await; + } + } +} diff --git a/sbtc-cli/src/commands/broadcast.rs b/sbtc-cli/src/commands/broadcast.rs index a2962484..93318849 100644 --- a/sbtc-cli/src/commands/broadcast.rs +++ b/sbtc-cli/src/commands/broadcast.rs @@ -10,10 +10,10 @@ use url::Url; #[derive(Parser, Debug, Clone)] pub struct BroadcastArgs { /// Where to broadcast the transaction - node_url: Url, + pub node_url: Url, /// The transaction to broadcast - tx: String, + pub tx: String, } pub fn broadcast_tx(broadcast: &BroadcastArgs) -> anyhow::Result<()> { diff --git a/sbtc-cli/src/commands/deposit.rs b/sbtc-cli/src/commands/deposit.rs index 4ad1190f..c78bcbf1 100644 --- a/sbtc-cli/src/commands/deposit.rs +++ b/sbtc-cli/src/commands/deposit.rs @@ -1,9 +1,9 @@ -use std::{io::stdout, str::FromStr}; +use std::str::FromStr; use bdk::{ bitcoin::{ - psbt::serialize::Serialize, Address as BitcoinAddress, - Network as BitcoinNetwork, PrivateKey, + Address as BitcoinAddress, Network as BitcoinNetwork, PrivateKey, + Transaction, }, blockchain::{ ConfigurableBlockchain, ElectrumBlockchain, ElectrumBlockchainConfig, @@ -17,36 +17,34 @@ use sbtc_core::operations::op_return::deposit::build_deposit_transaction; use stacks_core::utils::PrincipalData; use url::Url; -use crate::commands::utils; - #[derive(Parser, Debug, Clone)] pub struct DepositArgs { /// Where to broadcast the transaction #[clap(short('u'), long)] - node_url: Url, + pub node_url: Url, /// Bitcoin WIF of the P2wPKH address #[clap(short, long)] - wif: String, + pub wif: String, /// Bitcoin network where the deposit will be broadcasted to #[clap(short, long)] - network: BitcoinNetwork, + pub network: BitcoinNetwork, /// Stacks address that will receive sBTC #[clap(short, long)] - recipient: String, + pub recipient: String, /// The amount of sats to send #[clap(short, long)] - amount: u64, + pub amount: u64, /// Bitcoin address of the sbtc wallet #[clap(short, long)] - sbtc_wallet: String, + pub sbtc_wallet: String, } -pub fn build_deposit_tx(deposit: &DepositArgs) -> anyhow::Result<()> { +pub fn build_deposit_tx(deposit: &DepositArgs) -> anyhow::Result { let private_key = PrivateKey::from_wif(&deposit.wif)?; let blockchain = @@ -71,21 +69,12 @@ pub fn build_deposit_tx(deposit: &DepositArgs) -> anyhow::Result<()> { let stx_recipient = PrincipalData::try_from(deposit.recipient.to_string())?; let sbtc_wallet_address = BitcoinAddress::from_str(&deposit.sbtc_wallet)?; - let tx = build_deposit_transaction( + build_deposit_transaction( wallet, stx_recipient, sbtc_wallet_address, deposit.amount, deposit.network, - )?; - - serde_json::to_writer_pretty( - stdout(), - &utils::TransactionData { - id: tx.txid().to_string(), - hex: hex::encode(tx.serialize()), - }, - )?; - - Ok(()) + ) + .map_err(|e| e.into()) } diff --git a/sbtc-cli/src/commands/withdraw.rs b/sbtc-cli/src/commands/withdraw.rs index 774dce2f..0f431b4a 100644 --- a/sbtc-cli/src/commands/withdraw.rs +++ b/sbtc-cli/src/commands/withdraw.rs @@ -1,8 +1,8 @@ -use std::{io::stdout, str::FromStr}; +use std::str::FromStr; use bdk::{ bitcoin::{ - psbt::serialize::Serialize, Address as BitcoinAddress, + blockdata::transaction::Transaction, Address as BitcoinAddress, Network as BitcoinNetwork, PrivateKey, }, blockchain::{ @@ -15,45 +15,45 @@ use bdk::{ use clap::Parser; use url::Url; -use crate::commands::utils::TransactionData; - #[derive(Parser, Debug, Clone)] pub struct WithdrawalArgs { /// Where to broadcast the transaction #[clap(short('u'), long)] - node_url: Url, + pub node_url: Url, /// Bitcoin network where the deposit will be broadcasted to #[clap(short, long)] - network: BitcoinNetwork, + pub network: BitcoinNetwork, /// WIF of the Bitcoin P2WPKH address that will broadcast and pay for the /// withdrawal request #[clap(short, long)] - wif: String, + pub wif: String, /// WIF of the Stacks address that owns sBTC to be withdrawn #[clap(short, long)] - drawee_wif: String, + pub drawee_wif: String, /// Bitcoin address that will receive BTC #[clap(short('b'), long)] - payee_address: String, + pub payee_address: String, /// The amount of sats to withdraw #[clap(short, long)] - amount: u64, + pub amount: u64, /// The amount of sats to send for the fulfillment fee #[clap(short, long)] - fulfillment_fee: u64, + pub fulfillment_fee: u64, /// Bitcoin address of the sbtc wallet #[clap(short, long)] - sbtc_wallet: String, + pub sbtc_wallet: String, } -pub fn build_withdrawal_tx(withdrawal: &WithdrawalArgs) -> anyhow::Result<()> { +pub fn build_withdrawal_tx( + withdrawal: &WithdrawalArgs, +) -> anyhow::Result { let private_key = PrivateKey::from_wif(&withdrawal.wif)?; let blockchain = @@ -82,23 +82,14 @@ pub fn build_withdrawal_tx(withdrawal: &WithdrawalArgs) -> anyhow::Result<()> { let sbtc_wallet_bitcoin_address = BitcoinAddress::from_str(&withdrawal.sbtc_wallet)?; - let tx = sbtc_core::operations::op_return::withdrawal_request::build_withdrawal_tx( - &wallet, - withdrawal.network, - drawee_stacks_private_key, - payee_bitcoin_address, - sbtc_wallet_bitcoin_address, - withdrawal.amount, - withdrawal.fulfillment_fee, - )?; - - serde_json::to_writer_pretty( - stdout(), - &TransactionData { - id: tx.txid().to_string(), - hex: hex::encode(tx.serialize()), - }, - )?; - - Ok(()) + sbtc_core::operations::op_return::withdrawal_request::build_withdrawal_tx( + &wallet, + withdrawal.network, + drawee_stacks_private_key, + payee_bitcoin_address, + sbtc_wallet_bitcoin_address, + withdrawal.amount, + withdrawal.fulfillment_fee, + ) + .map_err(|e| e.into()) } diff --git a/sbtc-cli/src/lib.rs b/sbtc-cli/src/lib.rs new file mode 100644 index 00000000..82b6da3c --- /dev/null +++ b/sbtc-cli/src/lib.rs @@ -0,0 +1 @@ +pub mod commands; diff --git a/sbtc-cli/src/main.rs b/sbtc-cli/src/main.rs index 66e18699..51d0660f 100644 --- a/sbtc-cli/src/main.rs +++ b/sbtc-cli/src/main.rs @@ -5,18 +5,18 @@ //! //! It also allows you to generate credentials needed to generate transactions //! and interact with the Bitcoin and Stacks networks. +use std::io::stdout; +use bdk::bitcoin::{psbt::serialize::Serialize, Transaction}; use clap::{Parser, Subcommand}; - -use crate::commands::{ +use sbtc_cli::commands::{ broadcast::{broadcast_tx, BroadcastArgs}, deposit::{build_deposit_tx, DepositArgs}, generate::{generate, GenerateArgs}, + utils, withdraw::{build_withdrawal_tx, WithdrawalArgs}, }; -mod commands; - #[derive(Parser)] struct Cli { #[command(subcommand)] @@ -31,13 +31,30 @@ enum Command { GenerateFrom(GenerateArgs), } +fn to_stdout_pretty(txn: Transaction) -> serde_json::Result<()> { + serde_json::to_writer_pretty( + stdout(), + &utils::TransactionData { + id: txn.txid().to_string(), + hex: hex::encode(txn.serialize()), + }, + ) +} + fn main() -> Result<(), anyhow::Error> { let args = Cli::parse(); match args.command { - Command::Deposit(deposit_args) => build_deposit_tx(&deposit_args), + Command::Deposit(deposit_args) => build_deposit_tx(&deposit_args) + .and_then(|t| { + to_stdout_pretty(t)?; + Ok(()) + }), Command::Withdraw(withdrawal_args) => { - build_withdrawal_tx(&withdrawal_args) + build_withdrawal_tx(&withdrawal_args).and_then(|t| { + to_stdout_pretty(t)?; + Ok(()) + }) } Command::Broadcast(broadcast_args) => broadcast_tx(&broadcast_args), Command::GenerateFrom(generate_args) => generate(&generate_args), diff --git a/stacks-core/Cargo.toml b/stacks-core/Cargo.toml index 3ae104ec..f5dc81cb 100644 --- a/stacks-core/Cargo.toml +++ b/stacks-core/Cargo.toml @@ -27,7 +27,11 @@ serde = { workspace = true, features = ["derive"] } sha2.workspace = true strum = { workspace = true, features = ["derive"] } thiserror.workspace = true +blockstack-core.workspace = true [dev-dependencies] hex.workspace = true rand.workspace = true + +[features] +test-utils = [] diff --git a/stacks-core/src/address.rs b/stacks-core/src/address.rs index a002cb9e..6a4ac8b3 100644 --- a/stacks-core/src/address.rs +++ b/stacks-core/src/address.rs @@ -6,6 +6,10 @@ use std::{ use bdk::bitcoin::blockdata::{ opcodes::all::OP_CHECKMULTISIG, script::Builder, }; +use blockstack_lib::{ + codec::StacksMessageCodec, + types::chainstate::StacksAddress as CStacksAddress, +}; use serde::Serialize; use strum::{EnumIter, FromRepr}; @@ -100,6 +104,16 @@ impl StacksAddress { pub fn from_public_key(version: AddressVersion, key: &PublicKey) -> Self { Self::p2pkh(version, key) } + + /// Transmute to stacks-blockchain type + pub fn into_native_stacks_address(self) -> CStacksAddress { + use std::io::Cursor; + + CStacksAddress::consensus_deserialize(&mut Cursor::new( + self.serialize_to_vec(), + )) + .unwrap() + } } impl Codec for StacksAddress { @@ -226,6 +240,16 @@ fn hash_p2wsh<'a>( Hash160Hasher::new(&buff) } +#[cfg(feature = "test-utils")] +impl StacksAddress { + /// copy StacksAddress as a native stacks-blockchain address type + pub fn transmute_stacks_address(address: &str) -> CStacksAddress { + StacksAddress::try_from(address) + .unwrap() + .into_native_stacks_address() + } +} + #[cfg(test)] mod tests { use super::*;