diff --git a/.docker/test-nodes.yml b/.docker/test-nodes.yml new file mode 100644 index 0000000000..e5fc3e7d9b --- /dev/null +++ b/.docker/test-nodes.yml @@ -0,0 +1,222 @@ +# Docker Compose file for KDF test nodes +# +# Usage: +# Start all nodes: docker compose -f .docker/test-nodes.yml up -d +# Start specific: docker compose -f .docker/test-nodes.yml --profile utxo up -d +# Stop all: docker compose -f .docker/test-nodes.yml down -v +# View logs: docker compose -f .docker/test-nodes.yml logs -f +# +# Profiles: +# - utxo: MYCOIN, MYCOIN1 (basic UTXO testing) +# - slp: FORSLP (BCH/SLP token testing) +# - qrc20: QTUM (Qtum/QRC20 testing) +# - evm: GETH (Ethereum/ERC20 testing) +# - zombie: ZOMBIE (Zcash-based testing) +# - cosmos: NUCLEUS, ATOM, IBC-RELAYER (Tendermint/IBC testing) +# - sia: SIA (Sia testing) +# +# Node groups are controlled via compose profiles (see above). +# +# For CI/local reuse: +# KDF_DOCKER_COMPOSE_ENV=1 - Test harness attaches to running containers + +name: kdf-test-nodes + +services: + # ============================================================================ + # UTXO Test Nodes + # ============================================================================ + + mycoin: + image: docker.io/gleec/testblockchain:multiarch + profiles: ["utxo", "all"] + container_name: kdf-mycoin + ports: + - "8000:8000" + environment: + - CHAIN=MYCOIN + - CLIENTS=2 + - COIN=Komodo + - COIN_RPC_PORT=8000 + - DAEMON_URL=http://test:test@127.0.0.1:7000 + - TEST_ADDY=R9imXLs1hEcU9KbFDQq2hJEEJ1P5UoekaF + - TEST_WIF=UqqW7f766rADem9heD8vSBvvrdfJb3zg5r8du9rJxPtccjWf7RG9 + - TEST_PUBKEY=021607076d7a2cb148d542fb9644c04ffc22d2cca752f80755a0402a24c567b17a + volumes: + - ${ZCASH_PARAMS_PATH:-~/.zcash-params}:/root/.zcash-params:ro + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8000"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 10s + + mycoin1: + image: docker.io/gleec/testblockchain:multiarch + profiles: ["utxo", "all"] + container_name: kdf-mycoin1 + ports: + - "8001:8001" + environment: + - CHAIN=MYCOIN1 + - CLIENTS=2 + - COIN=Komodo + - COIN_RPC_PORT=8001 + - DAEMON_URL=http://test:test@127.0.0.1:7000 + - TEST_ADDY=R9imXLs1hEcU9KbFDQq2hJEEJ1P5UoekaF + - TEST_WIF=UqqW7f766rADem9heD8vSBvvrdfJb3zg5r8du9rJxPtccjWf7RG9 + - TEST_PUBKEY=021607076d7a2cb148d542fb9644c04ffc22d2cca752f80755a0402a24c567b17a + volumes: + - ${ZCASH_PARAMS_PATH:-~/.zcash-params}:/root/.zcash-params:ro + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8001"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 10s + + # ============================================================================ + # BCH/SLP Test Node + # ============================================================================ + + forslp: + image: docker.io/gleec/testblockchain:multiarch + profiles: ["slp", "all"] + container_name: kdf-forslp + ports: + - "10000:10000" + environment: + - CHAIN=FORSLP + - CLIENTS=2 + - COIN=Komodo + - COIN_RPC_PORT=10000 + - DAEMON_URL=http://test:test@127.0.0.1:7000 + - TEST_ADDY=R9imXLs1hEcU9KbFDQq2hJEEJ1P5UoekaF + - TEST_WIF=UqqW7f766rADem9heD8vSBvvrdfJb3zg5r8du9rJxPtccjWf7RG9 + - TEST_PUBKEY=021607076d7a2cb148d542fb9644c04ffc22d2cca752f80755a0402a24c567b17a + volumes: + - ${ZCASH_PARAMS_PATH:-~/.zcash-params}:/root/.zcash-params:ro + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:10000"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 10s + + # ============================================================================ + # Qtum/QRC20 Test Node + # ============================================================================ + + qtum: + image: docker.io/gleec/qtumregtest:latest + profiles: ["qrc20", "all"] + container_name: kdf-qtum + ports: + - "9000:9000" + environment: + - CLIENTS=2 + - COIN_RPC_PORT=9000 + - ADDRESS_LABEL=MM2_ADDRESS_LABEL + - FILL_MEMPOOL=true + healthcheck: + test: ["CMD-SHELL", "qtum-cli -rpcport=9000 getblockchaininfo || exit 1"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 15s + + # ============================================================================ + # Ethereum/Geth Dev Node + # ============================================================================ + + geth: + image: docker.io/ethereum/client-go:stable + profiles: ["evm", "all"] + container_name: kdf-geth + ports: + - "8545:8545" + command: ["--dev", "--http", "--http.addr=0.0.0.0", "--http.api=eth,net,web3,personal,debug", "--http.corsdomain=*"] + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8545 --post-data='{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"params\":[],\"id\":1}' --header='Content-Type: application/json' || exit 1"] + interval: 3s + timeout: 3s + retries: 30 + start_period: 5s + + # ============================================================================ + # Zcash-based (Zombie) Test Node + # ============================================================================ + + zombie: + image: docker.io/gleec/zombietestrunner:multiarch + profiles: ["zombie", "all"] + container_name: kdf-zombie + ports: + - "7090:7090" + environment: + - COIN_RPC_PORT=7090 + volumes: + - ${ZCASH_PARAMS_PATH:-~/.zcash-params}:/root/.zcash-params:ro + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:7090"] + interval: 5s + timeout: 3s + retries: 60 + start_period: 30s + + # ============================================================================ + # Cosmos/Tendermint Test Nodes (use host network for IBC) + # ============================================================================ + + nucleus: + image: docker.io/gleec/nucleusd:latest + profiles: ["cosmos", "all"] + container_name: kdf-nucleus + network_mode: host + volumes: + - ${KDF_CONTAINER_RUNTIME_DIR:-./container-runtime}/nucleus-testnet-data:/root/.nucleus + + atom: + image: docker.io/gleec/gaiad:kdf-ci + profiles: ["cosmos", "all"] + container_name: kdf-atom + network_mode: host + volumes: + - ${KDF_CONTAINER_RUNTIME_DIR:-./container-runtime}/atom-testnet-data:/root/.gaia + + ibc-relayer: + image: docker.io/gleec/ibc-relayer:kdf-ci + profiles: ["cosmos", "all"] + container_name: kdf-ibc-relayer + network_mode: host + volumes: + - ${KDF_CONTAINER_RUNTIME_DIR:-./container-runtime}/ibc-relayer-data:/root/.relayer + depends_on: + nucleus: + condition: service_started + atom: + condition: service_started + + # ============================================================================ + # Sia Test Node + # ============================================================================ + + sia: + image: ghcr.io/siafoundation/walletd:latest + profiles: ["sia", "all"] + container_name: kdf-sia + ports: + - "9980:9980" + environment: + - WALLETD_CONFIG_FILE=/config/walletd.yml + command: ["-network=/config/ci_network.json", "-debug"] + volumes: + - ${KDF_CONTAINER_RUNTIME_DIR:-./container-runtime}/sia-config:/config:ro + # No persistent volume for /data - use ephemeral storage like testcontainers + # to ensure fresh state each run and avoid address indexer lag issues + healthcheck: + test: ["CMD-SHELL", "wget -qO- --header='Authorization: Basic cGFzc3dvcmQ=' http://localhost:9980/api/state || exit 1"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 10s diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index d3a171cd43..d94c6c33c4 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -81,9 +81,9 @@ jobs: if: github.event_name != 'pull_request' && github.ref == 'refs/heads/dev' run: | CONTAINER_TAG="dev-$KDF_BUILD_TAG" - docker build -t komodoofficial/komodo-defi-framework:"$CONTAINER_TAG" -t komodoofficial/komodo-defi-framework:dev-latest -f .docker/Dockerfile.dev-release . - docker push komodoofficial/komodo-defi-framework:"$CONTAINER_TAG" - docker push komodoofficial/komodo-defi-framework:dev-latest + docker build -t gleec/komodo-defi-framework:"$CONTAINER_TAG" -t gleec/komodo-defi-framework:dev-latest -f .docker/Dockerfile.dev-release . + docker push gleec/komodo-defi-framework:"$CONTAINER_TAG" + docker push gleec/komodo-defi-framework:dev-latest mac-x86-64: timeout-minutes: 60 diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index a7991c325d..6ce7dedd0a 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -44,3 +44,13 @@ jobs: echo "PR title is too long (greater than 85 characters)" exit 1 fi + + - name: Check PR labels + env: + LABEL_NAMES: ${{ toJson(github.event.pull_request.labels.*.name) }} + if: "!((contains(env.LABEL_NAMES, 'pending review') && !contains(env.LABEL_NAMES, 'in progress') && !contains(env.LABEL_NAMES, 'blocked')) + || (!contains(env.LABEL_NAMES, 'pending review') && contains(env.LABEL_NAMES, 'in progress') && !contains(env.LABEL_NAMES, 'blocked')) + || (!contains(env.LABEL_NAMES, 'pending review') && !contains(env.LABEL_NAMES, 'in progress') && contains(env.LABEL_NAMES, 'blocked')))" + run: | + echo "PR must have "exactly one" of these labels: ['status: pending review', 'status: in progress', 'status: blocked']." + exit 1 diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml deleted file mode 100644 index a125dadeb9..0000000000 --- a/.github/workflows/pr-review-reminder.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: PR Review Reminder - -on: - schedule: - # Run at 12:00 PM everyday. - - cron: '0 12 * * *' - -jobs: - pr-review-reminder: - permissions: - contents: read - pull-requests: write - - runs-on: ubuntu-latest - name: PR Review Reminder - - steps: - - name: Run PR Review Reminder - uses: thundermiracle/review-reminder-action@224d83b90c76ac597776c79ba1f63539f0bc2795 - with: - stale-days: 2 - ignore-draft: true - # Don't ping people at weekends. - only-business-days: true - # 2 approvals are enough. - skip-approve-count: 2 - token: "${{ secrets.GITHUB_TOKEN }}" - send-reminder-comment: true diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index e84e622e3a..a91348568a 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -75,9 +75,9 @@ jobs: - name: Build and push container image run: | export CONTAINER_TAG=$(./target/release/kdf --version | awk '{print $3}') - docker build -t komodoofficial/komodo-defi-framework:"$CONTAINER_TAG" -t komodoofficial/komodo-defi-framework:main-latest -f .docker/Dockerfile.release . - docker push komodoofficial/komodo-defi-framework:"$CONTAINER_TAG" - docker push komodoofficial/komodo-defi-framework:main-latest + docker build -t gleec/komodo-defi-framework:"$CONTAINER_TAG" -t gleec/komodo-defi-framework:main-latest -f .docker/Dockerfile.release . + docker push gleec/komodo-defi-framework:"$CONTAINER_TAG" + docker push gleec/komodo-defi-framework:main-latest mac-x86-64: timeout-minutes: 60 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af01024734..15df0c4757 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,42 +12,37 @@ env: FROM_SHARED_RUNNER: true jobs: - linux-x86-64-unit: + unit: + name: Unit / ${{ matrix.os-name }} timeout-minutes: 90 - runs-on: ubuntu-latest - env: - BOB_PASSPHRASE: ${{ secrets.BOB_PASSPHRASE_LINUX }} - BOB_USERPASS: ${{ secrets.BOB_USERPASS_LINUX }} - ALICE_PASSPHRASE: ${{ secrets.ALICE_PASSPHRASE_LINUX }} - ALICE_USERPASS: ${{ secrets.ALICE_USERPASS_LINUX }} - TELEGRAM_API_KEY: ${{ secrets.TELEGRAM_API_KEY }} - steps: - - uses: actions/checkout@v3 - - name: Install toolchain - run: | - rustup toolchain install stable --no-self-update --profile=minimal - rustup default stable - - - name: Install build deps - uses: ./.github/actions/deps-install - with: - deps: ('protoc') - - - name: Build cache - uses: ./.github/actions/build-cache - - - name: Test - run: | - cargo test --bins --lib --no-fail-fast - - mac-x86-64-unit: - timeout-minutes: 90 - runs-on: macos-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + os-name: Linux + bob-passphrase-secret: BOB_PASSPHRASE_LINUX + bob-userpass-secret: BOB_USERPASS_LINUX + alice-passphrase-secret: ALICE_PASSPHRASE_LINUX + alice-userpass-secret: ALICE_USERPASS_LINUX + - os: macos-latest + os-name: macOS + bob-passphrase-secret: BOB_PASSPHRASE_MACOS + bob-userpass-secret: BOB_USERPASS_MACOS + alice-passphrase-secret: ALICE_PASSPHRASE_MACOS + alice-userpass-secret: ALICE_USERPASS_MACOS + - os: windows-latest + os-name: Windows + bob-passphrase-secret: BOB_PASSPHRASE_WIN + bob-userpass-secret: BOB_USERPASS_WIN + alice-passphrase-secret: ALICE_PASSPHRASE_WIN + alice-userpass-secret: ALICE_USERPASS_WIN env: - BOB_PASSPHRASE: ${{ secrets.BOB_PASSPHRASE_MACOS }} - BOB_USERPASS: ${{ secrets.BOB_USERPASS_MACOS }} - ALICE_PASSPHRASE: ${{ secrets.ALICE_PASSPHRASE_MACOS }} - ALICE_USERPASS: ${{ secrets.ALICE_USERPASS_MACOS }} + BOB_PASSPHRASE: ${{ secrets[matrix.bob-passphrase-secret] }} + BOB_USERPASS: ${{ secrets[matrix.bob-userpass-secret] }} + ALICE_PASSPHRASE: ${{ secrets[matrix.alice-passphrase-secret] }} + ALICE_USERPASS: ${{ secrets[matrix.alice-userpass-secret] }} TELEGRAM_API_KEY: ${{ secrets.TELEGRAM_API_KEY }} steps: - uses: actions/checkout@v3 @@ -68,42 +63,37 @@ jobs: run: | cargo test --bins --lib --no-fail-fast - win-x86-64-unit: + integration: + name: Integration / ${{ matrix.os-name }} timeout-minutes: 90 - runs-on: windows-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + os-name: Linux + bob-passphrase-secret: BOB_PASSPHRASE_LINUX + bob-userpass-secret: BOB_USERPASS_LINUX + alice-passphrase-secret: ALICE_PASSPHRASE_LINUX + alice-userpass-secret: ALICE_USERPASS_LINUX + - os: macos-latest + os-name: macOS + bob-passphrase-secret: BOB_PASSPHRASE_MACOS + bob-userpass-secret: BOB_USERPASS_MACOS + alice-passphrase-secret: ALICE_PASSPHRASE_MACOS + alice-userpass-secret: ALICE_USERPASS_MACOS + - os: windows-latest + os-name: Windows + bob-passphrase-secret: BOB_PASSPHRASE_WIN + bob-userpass-secret: BOB_USERPASS_WIN + alice-passphrase-secret: ALICE_PASSPHRASE_WIN + alice-userpass-secret: ALICE_USERPASS_WIN env: - BOB_PASSPHRASE: ${{ secrets.BOB_PASSPHRASE_WIN }} - BOB_USERPASS: ${{ secrets.BOB_USERPASS_WIN }} - ALICE_PASSPHRASE: ${{ secrets.ALICE_PASSPHRASE_WIN }} - ALICE_USERPASS: ${{ secrets.ALICE_USERPASS_WIN }} - TELEGRAM_API_KEY: ${{ secrets.TELEGRAM_API_KEY }} - steps: - - uses: actions/checkout@v3 - - name: Install toolchain - run: | - rustup toolchain install stable --no-self-update --profile=minimal - rustup default stable - - - name: Install build deps - uses: ./.github/actions/deps-install - with: - deps: ('protoc') - - - name: Build cache - uses: ./.github/actions/build-cache - - - name: Test - run: | - cargo test --bins --lib --no-fail-fast - - linux-x86-64-kdf-integration: - timeout-minutes: 90 - runs-on: ubuntu-latest - env: - BOB_PASSPHRASE: ${{ secrets.BOB_PASSPHRASE_LINUX }} - BOB_USERPASS: ${{ secrets.BOB_USERPASS_LINUX }} - ALICE_PASSPHRASE: ${{ secrets.ALICE_PASSPHRASE_LINUX }} - ALICE_USERPASS: ${{ secrets.ALICE_USERPASS_LINUX }} + BOB_PASSPHRASE: ${{ secrets[matrix.bob-passphrase-secret] }} + BOB_USERPASS: ${{ secrets[matrix.bob-userpass-secret] }} + ALICE_PASSPHRASE: ${{ secrets[matrix.alice-passphrase-secret] }} + ALICE_USERPASS: ${{ secrets[matrix.alice-userpass-secret] }} TELEGRAM_API_KEY: ${{ secrets.TELEGRAM_API_KEY }} steps: - uses: actions/checkout@v3 @@ -117,85 +107,143 @@ jobs: with: deps: ('protoc') - - name: Build cache - uses: ./.github/actions/build-cache - - - name: Test - run: | - wget -O - https://raw.githubusercontent.com/KomodoPlatform/komodo/635112d590618165a152dfa0f31e95a9be39a8f6/zcutil/fetch-params-alt.sh | bash - cargo test --test 'mm2_tests_main' --no-fail-fast - - mac-x86-64-kdf-integration: - timeout-minutes: 90 - runs-on: macos-latest - env: - BOB_PASSPHRASE: ${{ secrets.BOB_PASSPHRASE_MACOS }} - BOB_USERPASS: ${{ secrets.BOB_USERPASS_MACOS }} - ALICE_PASSPHRASE: ${{ secrets.ALICE_PASSPHRASE_MACOS }} - ALICE_USERPASS: ${{ secrets.ALICE_USERPASS_MACOS }} - TELEGRAM_API_KEY: ${{ secrets.TELEGRAM_API_KEY }} - steps: - - uses: actions/checkout@v3 - - name: Install toolchain - run: | - rustup toolchain install stable --no-self-update --profile=minimal - rustup default stable - - - name: Install build deps - uses: ./.github/actions/deps-install - with: - deps: ('protoc') - - - name: Set loopback address + - name: Set loopback address (macOS) + if: matrix.os == 'macos-latest' run: ./scripts/ci/lo0_config.sh - name: Build cache uses: ./.github/actions/build-cache - - name: Test - run: | - wget -O - https://raw.githubusercontent.com/KomodoPlatform/komodo/635112d590618165a152dfa0f31e95a9be39a8f6/zcutil/fetch-params-alt.sh | bash - cargo test --test 'mm2_tests_main' --no-fail-fast - - win-x86-64-kdf-integration: - timeout-minutes: 90 - runs-on: windows-latest - env: - BOB_PASSPHRASE: ${{ secrets.BOB_PASSPHRASE_WIN }} - BOB_USERPASS: ${{ secrets.BOB_USERPASS_WIN }} - ALICE_PASSPHRASE: ${{ secrets.ALICE_PASSPHRASE_WIN }} - ALICE_USERPASS: ${{ secrets.ALICE_USERPASS_WIN }} - TELEGRAM_API_KEY: ${{ secrets.TELEGRAM_API_KEY }} - steps: - - uses: actions/checkout@v3 - - name: Install toolchain - run: | - rustup toolchain install stable --no-self-update --profile=minimal - rustup default stable - - - name: Install build deps - uses: ./.github/actions/deps-install - with: - deps: ('protoc') - - - name: Build cache - uses: ./.github/actions/build-cache - - - name: Download wget64 + - name: Download wget64 (Windows) + if: matrix.os == 'windows-latest' uses: ./.github/actions/download-and-verify with: url: "https://github.com/KomodoPlatform/komodo/raw/d456be35acd1f8584e1e4f971aea27bd0644d5c5/zcutil/wget64.exe" output_file: "/wget64.exe" checksum: "d80719431dc22b0e4a070f61fab982b113a4ed9a6d4cf25e64b5be390dcadb94" + - name: Fetch zcash params (Linux/macOS) + if: matrix.os != 'windows-latest' + run: wget -O - https://raw.githubusercontent.com/KomodoPlatform/komodo/635112d590618165a152dfa0f31e95a9be39a8f6/zcutil/fetch-params-alt.sh | bash + + - name: Fetch zcash params (Windows) + if: matrix.os == 'windows-latest' + run: Invoke-WebRequest -Uri https://raw.githubusercontent.com/KomodoPlatform/komodo/635112d590618165a152dfa0f31e95a9be39a8f6/zcutil/fetch-params-alt.bat -OutFile \cmd.bat && \cmd.bat + - name: Test - run: | - Invoke-WebRequest -Uri https://raw.githubusercontent.com/KomodoPlatform/komodo/635112d590618165a152dfa0f31e95a9be39a8f6/zcutil/fetch-params-alt.bat -OutFile \cmd.bat && \cmd.bat - cargo test --test 'mm2_tests_main' --no-fail-fast + run: cargo test --test 'mm2_tests_main' --no-fail-fast - docker-tests: - timeout-minutes: 90 + # ========================================================================== + # Docker Test Suites (matrix job for clean GitHub UI grouping) + # ========================================================================== + docker: + name: Docker / ${{ matrix.name }} runs-on: ubuntu-latest + timeout-minutes: ${{ matrix.timeout }} + strategy: + fail-fast: false + matrix: + include: + # SLP tests - BCH/SLP token tests + - name: SLP + features: docker-tests-slp + compose-profiles: "--profile slp" + timeout: 45 + needs-zcash-params: true + needs-nodes-setup: false + nodes-setup-args: "" + container-wait-time: 15 + + # Sia tests - Sia + UTXO for swaps + - name: Sia + features: docker-tests-sia + compose-profiles: "--profile sia --profile utxo" + timeout: 30 + needs-zcash-params: true + needs-nodes-setup: true + nodes-setup-args: "--skip-cosmos" + container-wait-time: 15 + + # ETH/EVM tests - Ethereum and ERC20 + - name: ETH + features: docker-tests-eth + compose-profiles: "--profile evm" + timeout: 60 + needs-zcash-params: false + needs-nodes-setup: false + nodes-setup-args: "" + container-wait-time: 15 + + # Ordermatching tests - UTXO order lifecycle + - name: Ordermatch (UTXO) + features: docker-tests-ordermatch + compose-profiles: "--profile utxo" + timeout: 60 + needs-zcash-params: true + needs-nodes-setup: false + nodes-setup-args: "" + container-wait-time: 15 + + # UTXO swap protocol tests + - name: Swaps (UTXO) + features: docker-tests-swaps + compose-profiles: "--profile utxo" + timeout: 60 + needs-zcash-params: true + needs-nodes-setup: false + nodes-setup-args: "" + container-wait-time: 20 + + # Watcher tests - UTXO watcher flows + - name: Watchers (UTXO) + features: docker-tests-watchers + compose-profiles: "--profile utxo" + timeout: 60 + needs-zcash-params: true + needs-nodes-setup: false + nodes-setup-args: "" + container-wait-time: 15 + + # QRC20/Qtum tests + - name: QRC20 + features: docker-tests-qrc20 + compose-profiles: "--profile qrc20 --profile utxo" + timeout: 45 + needs-zcash-params: true + needs-nodes-setup: false + nodes-setup-args: "" + container-wait-time: 20 + + # Tendermint/Cosmos tests + - name: Tendermint + features: docker-tests-tendermint + compose-profiles: "--profile cosmos" + timeout: 60 + needs-zcash-params: false + needs-nodes-setup: true + nodes-setup-args: "--skip-sia" + container-wait-time: 30 + + # ZCoin/Zombie tests + - name: ZCoin + features: docker-tests-zcoin + compose-profiles: "--profile zombie" + timeout: 60 + needs-zcash-params: true + needs-nodes-setup: false + nodes-setup-args: "" + container-wait-time: 30 + + # Cross-chain integration tests + - name: Integration (Cross-chain) + features: docker-tests-integration + compose-profiles: "--profile all" + timeout: 90 + needs-zcash-params: true + needs-nodes-setup: true + nodes-setup-args: "" + container-wait-time: 45 + env: BOB_PASSPHRASE: ${{ secrets.BOB_PASSPHRASE_LINUX }} BOB_USERPASS: ${{ secrets.BOB_USERPASS_LINUX }} @@ -204,6 +252,7 @@ jobs: TELEGRAM_API_KEY: ${{ secrets.TELEGRAM_API_KEY }} steps: - uses: actions/checkout@v3 + - name: Install toolchain run: | rustup toolchain install stable --no-self-update --profile=minimal @@ -217,12 +266,32 @@ jobs: - name: Build cache uses: ./.github/actions/build-cache - - name: Test + - name: Fetch zcash params + if: ${{ matrix.needs-zcash-params }} + run: wget -O - https://raw.githubusercontent.com/KomodoPlatform/komodo/v0.8.1/zcutil/fetch-params-alt.sh | bash + + - name: Prepare docker test environment + if: ${{ matrix.needs-nodes-setup }} + run: ./scripts/ci/docker-test-nodes-setup.sh ${{ matrix.nodes-setup-args }} + + - name: Start docker nodes run: | - wget -O - https://raw.githubusercontent.com/KomodoPlatform/komodo/v0.8.1//zcutil/fetch-params-alt.sh | bash - cargo test --test 'docker_tests_main' --features run-docker-tests --no-fail-fast + docker compose -f .docker/test-nodes.yml ${{ matrix.compose-profiles }} up -d + echo "Waiting for containers to initialize..." + sleep ${{ matrix.container-wait-time }} + docker compose -f .docker/test-nodes.yml ps + + - name: Run tests + env: + KDF_DOCKER_COMPOSE_ENV: "1" + run: cargo test --test 'docker_tests_main' --features ${{ matrix.features }} --no-fail-fast + + - name: Stop docker nodes + if: always() + run: docker compose -f .docker/test-nodes.yml down -v wasm: + name: WASM timeout-minutes: 90 runs-on: ubuntu-latest env: diff --git a/.gitignore b/.gitignore index 47d4ca0e5e..c908f46a19 100755 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,10 @@ hidden # It is recreated from container-state data each time test containers are started, # and should not be tracked in version control. .docker/container-runtime/ + +# Claude Code symlinks (generated from AGENTS.md) +CLAUDE.md +mm2src/*/CLAUDE.md + +# Claude Code configuration +.claude diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..35adc49819 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,261 @@ +# AGENTS.md β€” Komodo DeFi Framework + +> **CURRENT STATUS & ROADMAP**: We use specific plan files to track large features, refactors, or complex fixes. +> +> πŸ‘‰ **See [`docs/plans/`](docs/plans/) for active objectives.** +> +> *Always update the corresponding plan file when completing tasks. Delete the plan file when all tasks in the plan are complete.* + +Guide for AI-assisted development. Keep changes small, follow patterns. + +## Project Overview + +Komodo DeFi Framework (KDF) is an open-source atomic-swap DEX enabling trustless P2P trading across blockchains. Core capabilities: +- **Atomic swaps** via HTLCs (Hash Time Locked Contracts) +- **Multi-chain support**: UTXO, EVM, Tendermint, Zcash, Lightning, Sia, Solana +- **Wallets**: + - HD wallets: BIP39/BIP32/SLIP-10 derivation + - Hardware: Trezor (native) + - External: WalletConnect v2 (native + WASM), MetaMask (WASM only) + +Targets: Linux (x86-64), macOS (x86-64, ARM64, Universal), Windows (x86-64), WASM, iOS (aarch64), Android (aarch64, armv7). + +## Workspace Layout + +All crates reside in `mm2src/`. Crates with AGENTS.md files are marked with `β†’`. + +``` +mm2src/ +β”œβ”€β”€ mm2_bin_lib/ # Platform entry points (native/WASM/mobile) β†’ see AGENTS.md +β”œβ”€β”€ mm2_main/ # App entry, RPC, swaps, ordermatch β†’ see AGENTS.md +β”œβ”€β”€ coins/ # Multi-protocol coin support β†’ see AGENTS.md +β”‚ └── utxo_signer/ # UTXO transaction signing (keypair/Trezor) +β”œβ”€β”€ crypto/ # Key management, HD derivation β†’ see AGENTS.md +β”œβ”€β”€ mm2_core/ # MmArc/MmCtx central context, event dispatch +β”œβ”€β”€ mm2_p2p/ # libp2p networking, gossipsub β†’ see AGENTS.md +β”œβ”€β”€ coins_activation/ # Coin activation flows β†’ see AGENTS.md +β”œβ”€β”€ trezor/ # Trezor hardware wallet β†’ see AGENTS.md +β”œβ”€β”€ mm2_bitcoin/ # UTXO primitives β†’ see AGENTS.md +β”‚ β”œβ”€β”€ chain/ # Block/transaction structures +β”‚ β”œβ”€β”€ crypto/ # Hash functions (bitcrypto) +β”‚ β”œβ”€β”€ keys/ # Address and key management +β”‚ β”œβ”€β”€ primitives/ # H160, H256, U256 types +β”‚ β”œβ”€β”€ script/ # Bitcoin scripting +β”‚ β”œβ”€β”€ serialization/ # Binary encoding +β”‚ β”œβ”€β”€ serialization_derive/ +β”‚ β”œβ”€β”€ rpc/ # RPC response types +β”‚ β”œβ”€β”€ spv_validation/ # SPV proof verification +β”‚ └── test_helpers/ # Testing utilities +β”œβ”€β”€ common/ # Shared utilities β†’ see AGENTS.md +β”‚ └── shared_ref_counter/ # Debug-instrumented Arc +β”œβ”€β”€ kdf_walletconnect/ # WalletConnect v2 protocol +β”œβ”€β”€ mm2_event_stream/ # SSE streaming infrastructure +β”œβ”€β”€ mm2_err_handle/ # MmError framework +β”œβ”€β”€ mm2_net/ # HTTP/WebSocket/gRPC-web networking +β”œβ”€β”€ mm2_rpc/ # RPC data types and protocol +β”œβ”€β”€ mm2_db/ # IndexedDB wrapper (WASM only) +β”œβ”€β”€ mm2_eth/ # Ethereum utilities, EIP-712 +β”œβ”€β”€ mm2_metamask/ # MetaMask integration (WASM only) +β”œβ”€β”€ mm2_number/ # High-precision numerics (MmNumber) +β”œβ”€β”€ mm2_state_machine/ # Generic state machine framework +β”œβ”€β”€ mm2_metrics/ # Prometheus metrics +β”œβ”€β”€ mm2_io/ # File I/O (native only) +β”œβ”€β”€ mm2_git/ # GitHub API client +β”œβ”€β”€ mm2_gui_storage/ # GUI persistence layer +β”œβ”€β”€ rpc_task/ # Long-running RPC task framework +β”œβ”€β”€ trading_api/ # External DEX integration (1inch) +β”œβ”€β”€ proxy_signature/ # libp2p message signing for proxy auth +β”œβ”€β”€ db_common/ # SQLite abstractions (native) +β”œβ”€β”€ hw_common/ # Hardware wallet abstractions +β”œβ”€β”€ ledger/ # Ledger device protocol (scaffolding only, not integrated) +β”œβ”€β”€ derives/ +β”‚ β”œβ”€β”€ enum_derives/ # Enum conversion macros +β”‚ β”œβ”€β”€ ser_error/ # Error serialization trait +β”‚ └── ser_error_derive/ # Error serialization macro +└── mm2_test_helpers/ # Testing utilities (excluded from workspace) +``` + +## Global Conventions + +### Performance +- Prefer optimal solutions over quick fixesβ€”check other crates for existing efficient implementations before writing new code. Consider algorithmic complexity. + +### Rust Style +- `cargo fmt` before commit +- `cargo clippy --all-targets --all-features -- -D warnings` (zero warnings) +- Prefer absolute imports from crate root over deep `super::` chains +- `async`/`await` only; avoid blocking in async context + +### Error Handling (MmError) +```rust +use common::HttpStatusCode; +use derive_more::Display; +use http::StatusCode; +use mm2_err_handle::prelude::*; +use ser_error_derive::SerializeErrorType; + +#[derive(Display, Serialize, SerializeErrorType)] // Debug optional +#[serde(tag = "error_type", content = "error_data")] +pub enum MyError { + #[display(fmt = "Not found: {}", _0)] + NotFound(String), + #[display(fmt = "Internal error: {}", _0)] + InternalError(String), +} + +impl HttpStatusCode for MyError { + fn status_code(&self) -> StatusCode { + match self { + MyError::NotFound(_) => StatusCode::NOT_FOUND, + MyError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} +// Usage: MmResult, convert with .map_to_mm()? +``` + +### Platform Guards +```rust +// Attribute-based: for modules, functions, enum variants, impls, match arms +#[cfg(not(target_arch = "wasm32"))] // Native only +pub mod lightning; + +#[cfg(target_arch = "wasm32")] // WASM only +fn wasm_only_fn() { } + +// Macro-based: for grouping multiple imports +cfg_native! { + use crate::lightning::LightningCoin; + use std::path::PathBuf; +} +cfg_wasm32! { + use mm2_db::indexed_db::SharedDb; +} +``` + +## Security Rules + +### Sensitive Data +1. **Never log/serialize**: mnemonics, seeds, private keys, extended keys, session tokens +2. **Zeroize secrets on drop**: use `zeroize` crate for sensitive types (see `Bip39Seed`) +3. **Sanitize error messages**: no internal paths, keys, or sensitive data in errors + +### Input Validation +4. **Validate all RPC inputs**: bounds, formats, existence checks +5. **Use strict types over strings**: prefer typed structs over raw `String`/`Value` for API boundaries +6. **Specify bounds**: use bounded integers, fixed-size arrays where lengths are known + +### Code Safety +7. **No `unwrap()`/`expect()`** in RPC paths without justification +8. **Avoid panics**: return `MmError` instead of panicking in library code + +*Note: Codebase is progressively improving; some legacy code may not follow all rules.* + +*When working on any code, if you identify a security-related pattern that could be generalized as a rule, propose adding it here.* + +## Build & Test + +```bash +# Build +cargo build --release + +# Unit tests +cargo test --bins --lib + +# Integration tests +cargo test --test 'mm2_tests_main' + +# Docker tests +cargo test --test 'docker_tests_main' --features run-docker-tests + +# Clippy (must pass) +cargo clippy --all-targets --all-features -- -D warnings +``` + +See `docs/DEV_ENVIRONMENT.md` for full setup and running specific tests. + +### Test Environment Variables + +Set these environment variables before running docker or integration tests: + +```bash +export BOB_PASSPHRASE="also shoot benefit prefer juice shell elder veteran woman mimic image kidney" +export ALICE_PASSPHRASE="spice describe gravity federal blast come thank unfair canal monkey style afraid" +``` + +## Testing + +- **Bug fixes**: Prefer writing a failing test first, then fix the bug +- **New features**: Scaffold first, then prefer writing tests before implementing logic where practical +- Always run new/modified tests in isolation to verify they pass +- After large features or refactors, suggest running the full test suite to check for regressions +- Use `#[serde(deny_unknown_fields)]` in test deserialization structs to catch unexpected fields + +## CI/CD + +- **Workflows**: `.github/workflows/` + - `test.yml` β€” Unit, integration, docker, and WASM tests + - `fmt-and-lint.yml` β€” Format and clippy (native + WASM) + - `dev-build.yml` β€” Dev builds for all targets + - `release-build.yml` β€” Release builds on `main` +- **Toolchain**: `stable` (see `rust-toolchain.toml`) +- **Builds**: Linux, macOS (x86/ARM/Universal), Windows, WASM, iOS, Android +- **Docker**: Images pushed on `dev` and `main` branches + +## RPC Overview + +| Namespace | Example | Purpose | +|-----------|---------|---------| +| (none) | `"withdraw"` | Stable APIs | +| `task::` | `"task::withdraw::init"` | Long-running ops (init/status/user_action/cancel) | +| `stream::` | `"stream::balance::enable"` | SSE subscriptions | +| `gui_storage::` | `"gui_storage::add_account"` | GUI state persistence | +| `lightning::` | `"lightning::channels::open_channel"` | Lightning Network (native only) | +| `experimental::` | `"experimental::..."` | Unstable APIs (may have sub-namespaces) | + +See `mm2_main/src/rpc/dispatcher/dispatcher.rs` for all methods, `mm2_main/AGENTS.md` for adding new handlers. + +## Key File Locations + +| Component | Location | +|-----------|----------| +| MmCtx (central context) | `mm2_core/src/mm_ctx.rs` | +| RPC dispatcher | `mm2_main/src/rpc/dispatcher/dispatcher.rs` | +| RPC handlers | `mm2_main/src/rpc/lp_commands/` | +| Order matching | `mm2_main/src/lp_ordermatch.rs` | +| Swap V1 | `mm2_main/src/lp_swap/{maker,taker}_swap.rs` | +| Swap V2 | `mm2_main/src/lp_swap/{maker,taker}_swap_v2.rs` | +| Watchers | `mm2_main/src/lp_swap/swap_watcher.rs` | +| Coin traits | `coins/lp_coins.rs` | +| CryptoCtx | `crypto/src/crypto_ctx.rs` | +| HD derivation | `crypto/src/global_hd_ctx.rs` | +| P2P behaviour | `mm2_p2p/src/behaviours/atomicdex.rs` | +| Coin activation | `coins_activation/src/platform_coin_with_tokens.rs` | + +## Working on Large Features + +For significant features or large refactors, make small, self-contained commits incrementally. Each commit should be a logical unit that leaves the codebase working. + +## Keeping Documentation Current + +Update relevant AGENTS.md files when changing module structure, key types, patterns, or conventions. + +## Common Pitfalls + +| Issue | Solution | +|-------|----------| +| Wrote suboptimal code when efficient implementation existed in another crate | Search other crates for reusable functions; make private functions public if needed | +| Large refactor done in one massive commit | Break into small, self-contained commits as you work | +| Changed public API but didn't update AGENTS.md | Update documentation alongside code changes | +| Compared against wrong branch (e.g., deprecated `mm2.1`) | Use `git merge-base HEAD origin/dev origin/staging origin/main` to find the common ancestor, or ask the user which branch the feature is based on. Branch hierarchy: `main` ← `staging` ← `dev` ← feature branches | +| Forgot to run `cargo fmt` before committing | Always run `cargo fmt` before committing. CI will fail on unformatted code | +| Forgot to run clippy before committing | Run clippy on changed crates before committing. For speed, target only modified crate(s): `cargo clippy -p `. If code uses feature flags, include relevant features. If WASM-only code changed, also run: `cargo clippy -p --target wasm32-unknown-unknown` | + +## Documentation + +- `README.md` β€” Build overview +- `docs/DEV_ENVIRONMENT.md` β€” Full test setup +- `docs/WASM_BUILD.md` β€” WASM build setup +- `docs/PR_REVIEW_CHECKLIST.md` β€” PR review checklist +- `docs/CONTRIBUTING.md` β€” Contribution guidelines +- `docs/GIT_FLOW_AND_WORKING_PROCESS.md` β€” Branch strategy diff --git a/CHANGELOG.md b/CHANGELOG.md index 7117e7ca8a..864dfbf8a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,51 @@ +## v3.0.0-beta - 2026-03-06 + +### Features: + +**TRON Wallet-Only Support**: +- Added initial TRON blockchain integration with HD wallet activation, TRC20 token support, transaction signing, fee estimation, and withdrawals. [#2467](https://github.com/KomodoPlatform/komodo-defi-framework/pull/2467) [#2712](https://github.com/KomodoPlatform/komodo-defi-framework/pull/2712) [#2714](https://github.com/KomodoPlatform/komodo-defi-framework/pull/2714) + +**WalletConnect**: +- Implemented BTC/UTXO transaction signing and legacy swap (v1) support via WalletConnect. [#2566](https://github.com/KomodoPlatform/komodo-defi-framework/pull/2566) + +**UTXO**: +- Added RXD `ForkIdRxd` sighash signing path for Radiant chain support. [#2713](https://github.com/KomodoPlatform/komodo-defi-framework/pull/2713) + +--- + +### Enhancements/Fixes: + +**EVM / ETH**: +- Added SafeERC20 V1 USDT support and docker tests. [#2711](https://github.com/KomodoPlatform/komodo-defi-framework/pull/2711) + +--- + +### Other Changes: + +**Infrastructure / CI**: +- Migrated service endpoints to gleec.com. [#2704](https://github.com/KomodoPlatform/komodo-defi-framework/pull/2704) +- Migrated docker image push to GLEEC organization. [#2705](https://github.com/KomodoPlatform/komodo-defi-framework/pull/2705) +- Split docker tests into feature-gated parallel suites. [#2707](https://github.com/KomodoPlatform/komodo-defi-framework/pull/2707) +- Removed review reminder bot. [#2706](https://github.com/KomodoPlatform/komodo-defi-framework/pull/2706) + +**Documentation**: +- Added AGENTS.md documentation across key crates. [#2465](https://github.com/KomodoPlatform/komodo-defi-framework/pull/2465) + +**Legal**: +- Updated LICENSE IP ownership. + +--- + +### NB - Backwards compatibility breaking changes: + +**P2P Network**: +- Default P2P netid changed from 8762 to 6133 as part of GLEEC rebranding; netid 8762 added to the deprecated list. Nodes on the old default netid will no longer discover peers on the new default. [#2710](https://github.com/KomodoPlatform/komodo-defi-framework/pull/2710) + +**Dex Fees**: +- Dex fee rate updated to 2% with a 1% discount for GLEEC; fee collection addresses migrated to GLEEC. [#2710](https://github.com/KomodoPlatform/komodo-defi-framework/pull/2710) + +--- + ## v2.6.0-beta - 2025-11-28 ### Features: diff --git a/Cargo.lock b/Cargo.lock index bc3fda945c..c908578310 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -982,6 +982,7 @@ dependencies = [ "http 0.2.12", "hyper 0.14.26", "hyper-rustls 0.24.2", + "instant", "itertools", "js-sys", "jsonrpc-core", @@ -1126,6 +1127,7 @@ dependencies = [ "arrayref", "async-trait", "backtrace", + "base64 0.21.7", "bytes", "cc", "cfg-if 1.0.0", @@ -4215,7 +4217,7 @@ dependencies = [ [[package]] name = "mm2_bin_lib" -version = "2.6.0-beta" +version = "3.0.0-beta" dependencies = [ "chrono", "common", @@ -4679,7 +4681,9 @@ dependencies = [ "db_common", "futures 0.3.31", "gstuff", + "hex", "http 0.2.12", + "kdf_walletconnect", "lazy_static", "mm2_core", "mm2_io", diff --git a/README.md b/README.md index 6b67fc4086..b9587bf3a2 100755 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ docker run -v "$(pwd)":/app -w /app kdf-build-container cargo build Just like building it on your host system, you will now have the target directory containing the build files. -Alternatively, container images are available on [DockerHub](https://hub.docker.com/r/komodoofficial/komodo-defi-framework) +Alternatively, container images are available on [DockerHub](https://hub.docker.com/r/gleec/komodo-defi-framework) ## Building WASM binary diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 4ff9aca963..035921ccd8 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -49,5 +49,5 @@ and we use [rustfmt](https://github.com/rust-lang/rustfmt) to make our code clea 1. Download Gecko driver for your OS: https://github.com/mozilla/geckodriver/releases 1. Run the tests ``` - WASM_BINDGEN_TEST_TIMEOUT=180 GECKODRIVER=PATH_TO_GECKO_DRIVER_BIN wasm-pack test --firefox --headless mm2src/mm2_main + WASM_BINDGEN_TEST_TIMEOUT=600 GECKODRIVER=PATH_TO_GECKO_DRIVER_BIN wasm-pack test --firefox --headless mm2src/mm2_main ``` diff --git a/docs/DEV_ENVIRONMENT.md b/docs/DEV_ENVIRONMENT.md index bab2e2db6a..537a1bb10f 100644 --- a/docs/DEV_ENVIRONMENT.md +++ b/docs/DEV_ENVIRONMENT.md @@ -54,7 +54,7 @@ 4. Set environment variables required to run WASM tests ```shell # wasm-bindgen specific variables - export WASM_BINDGEN_TEST_TIMEOUT=180 + export WASM_BINDGEN_TEST_TIMEOUT=600 export GECKODRIVER=PATH_TO_GECKO_DRIVER_BIN # MarketMaker specific variables export BOB_PASSPHRASE="also shoot benefit prefer juice shell elder veteran woman mimic image kidney" @@ -105,3 +105,35 @@ There are two primary methods for running specific tests: ``` PS If you notice that this guide is outdated, please submit a PR. + +## AI coding agents setup + +This project uses `AGENTS.md` as the canonical source for AI agent instructions, following the emerging standard for AI-assisted development. + +### File layout + +- Root `AGENTS.md` β€” main instructions for the entire project. +- `mm2src//AGENTS.md` β€” crate-specific instructions (mm2_main, coins, mm2_bitcoin, common, etc.). +- Crates without their own AGENTS.md inherit the root instructions. + +### Claude Code + +Claude Code does not yet support `AGENTS.md` directlyβ€”it expects a file named `CLAUDE.md`. Run this script from the repo root to create all necessary symlinks: + +```bash +#!/bin/bash +# Create CLAUDE.md symlinks for Claude Code compatibility + +# Root symlink +[ -f AGENTS.md ] && [ ! -e CLAUDE.md ] && ln -s AGENTS.md CLAUDE.md + +# Crate symlinks +for agents_file in mm2src/*/AGENTS.md; do + dir=$(dirname "$agents_file") + [ ! -e "$dir/CLAUDE.md" ] && ln -s AGENTS.md "$dir/CLAUDE.md" +done + +echo "Symlinks created." +``` + +When working in a crate, run Claude Code from that crate directory so it loads the crate's AGENTS.md via the symlink. diff --git a/docs/DOCKER_TESTS.md b/docs/DOCKER_TESTS.md new file mode 100644 index 0000000000..5cdfb3d9e9 --- /dev/null +++ b/docs/DOCKER_TESTS.md @@ -0,0 +1,88 @@ +# Docker Tests + +Docker tests run against local blockchain nodes to verify atomic swap functionality. + +## Prerequisites + +1. **Docker**: Install Docker Desktop or Docker Engine +2. **Zcash Parameters** (for UTXO nodes): + ```bash + wget -O - https://raw.githubusercontent.com/KomodoPlatform/komodo/v0.8.1/zcutil/fetch-params-alt.sh | bash + ``` + +## Quick Start + +```bash +# Run all tests (testcontainers mode - starts containers automatically) +cargo test --test docker_tests_main --features docker-tests-all +``` + +## Running Specific Test Suites + +Tests are split by feature flag. Use the flag for the suite you want: + +| Feature | What it tests | +|---------|---------------| +| `docker-tests-eth` | ETH/ERC20/NFT | +| `docker-tests-slp` | BCH/SLP tokens | +| `docker-tests-sia` | Sia | +| `docker-tests-qrc20` | Qtum/QRC20 | +| `docker-tests-tendermint` | Cosmos/IBC | +| `docker-tests-zcoin` | ZCoin/Zombie | +| `docker-tests-swaps` | Swap protocol | +| `docker-tests-ordermatch` | Ordermatching | +| `docker-tests-watchers` | Watcher nodes | +| `docker-tests-integration` | Cross-chain swaps | +| `docker-tests-all` | Everything | + +```bash +# Example: run only ETH tests +cargo test --test docker_tests_main --features docker-tests-eth +``` + +## Docker Compose Mode (Faster Development) + +Keep nodes running between test runs for faster iteration: + +```bash +# 1. Prepare environment (needed for Cosmos & Sia tests) +./scripts/ci/docker-test-nodes-setup.sh + +# 2. Start nodes (use profile for specific chains) +docker compose -f .docker/test-nodes.yml --profile all up -d + +# 3. Run tests against running containers +KDF_DOCKER_COMPOSE_ENV=1 cargo test --test docker_tests_main --features docker-tests-eth + +# 4. Stop when done +docker compose -f .docker/test-nodes.yml --profile all down -v +``` + +**Profiles**: `utxo`, `slp`, `qrc20`, `evm`, `zombie`, `cosmos`, `sia`, `all` + +## Troubleshooting + +**Containers won't start**: Check Docker is running (`docker info`) + +**Port conflicts**: Stop existing containers (`docker compose -f .docker/test-nodes.yml down`) + +**Stale state**: Clean up and restart: +```bash +docker compose -f .docker/test-nodes.yml down -v +rm -rf .docker/container-runtime +./scripts/ci/docker-test-nodes-setup.sh +``` + +**UTXO nodes fail**: Ensure zcash params are downloaded (see Prerequisites) + +## Test Nodes + +| Node | Image | Port | +|------|-------|------| +| MYCOIN/MYCOIN1 | `gleec/testblockchain:multiarch` | 8000/8001 | +| FORSLP | `gleec/testblockchain:multiarch` | 10000 | +| QTUM | `gleec/qtumregtest:latest` | 9000 | +| GETH | `ethereum/client-go:stable` | 8545 | +| ZOMBIE | `gleec/zombietestrunner:multiarch` | 7090 | +| NUCLEUS/ATOM | `gleec/nucleusd:latest`, `gleec/gaiad:kdf-ci` | 26657/26658 | +| SIA | `ghcr.io/siafoundation/walletd:latest` | 9980 | diff --git a/mm2src/adex_cli/src/scenarios/init_mm2_cfg.rs b/mm2src/adex_cli/src/scenarios/init_mm2_cfg.rs index 1fef03bf30..4686b56a58 100644 --- a/mm2src/adex_cli/src/scenarios/init_mm2_cfg.rs +++ b/mm2src/adex_cli/src/scenarios/init_mm2_cfg.rs @@ -14,7 +14,7 @@ use super::inquire_extentions::{InquireOption, DEFAULT_DEFAULT_OPTION_BOOL_FORMA use crate::helpers; use crate::logging::error_anyhow; -const DEFAULT_NET_ID: u16 = 8762; +const DEFAULT_NET_ID: u16 = 6133; const DEFAULT_GID: &str = "adex-cli"; const DEFAULT_OPTION_PLACEHOLDER: &str = "Tap enter to skip"; const RPC_PORT_MIN: u16 = 1024; @@ -128,7 +128,7 @@ impl Mm2Cfg { fn inquire_net_id(&mut self) -> Result<()> { self.netid = CustomType::::new("What is the network `mm2` is going to be a part, netid:") .with_default(DEFAULT_NET_ID) - .with_help_message(r#"Network ID number, telling the Komodo DeFi Framework which network to join. 8762 is the current main network, though alternative netids can be used for testing or "private" trades"#) + .with_help_message(r#"Network ID number, telling the Komodo DeFi Framework which network to join. 6133 is the current main network, though alternative netids can be used for testing or "private" trades"#) .with_placeholder(format!("{DEFAULT_NET_ID}").as_str()) .prompt() .map_err(|error| @@ -283,7 +283,7 @@ impl Mm2Cfg { .with_formatter(DEFAULT_OPTION_BOOL_FORMATTER) .with_default_value_formatter(DEFAULT_DEFAULT_OPTION_BOOL_FORMATTER) .with_default(InquireOption::None) - .with_help_message("Runs Komodo DeFi Framework as a seed node mode (acting as a relay for Komodo DeFi Framework clients). Optional, defaults to false. Use of this mode is not reccomended on the main network (8762) as it could result in a pubkey ban if non-compliant. on alternative testing or private networks, at least one seed node is required to relay information to other Komodo DeFi Framework clients using the same netID.") + .with_help_message("Runs Komodo DeFi Framework as a seed node mode (acting as a relay for Komodo DeFi Framework clients). Optional, defaults to false. Use of this mode is not reccomended on the main network (6133) as it could result in a pubkey ban if non-compliant. on alternative testing or private networks, at least one seed node is required to relay information to other Komodo DeFi Framework clients using the same netID.") .prompt() .map_err(|error| error_anyhow!("Failed to get i_am_a_seed: {error}") diff --git a/mm2src/coins/AGENTS.md b/mm2src/coins/AGENTS.md new file mode 100644 index 0000000000..ed83f6d0a4 --- /dev/null +++ b/mm2src/coins/AGENTS.md @@ -0,0 +1,172 @@ +# coins β€” Multi-Protocol Coin Support + +> **Note:** Always follow the root `/CLAUDE.md` for global conventions (fmt, clippy, error handling, etc.). + +Abstraction layer for blockchain protocols. Defines traits for swaps, balances, and transactions. + +## Responsibilities + +- Unified coin interface (`MmCoin` trait, `MmCoinEnum` wrapper) +- Protocol implementations: UTXO, EVM, Tendermint, Zcash, Lightning, Sia, Solana +- Swap trait implementations for atomic swap protocols +- HD wallet management (accounts, addresses, derivation, storage) +- Transaction building, signing, broadcasting, and history +- Balance tracking +- NFT support (EVM chains) +- Price fetching + +## Core Traits + +`MmCoinEnum` wraps all coin types, derefs to `dyn MmCoin`. + +| Trait | Purpose | Key Methods | +|-------|---------|-------------| +| `MmCoin` | Universal interface | Base trait all coins implement | +| `MarketCoinOps` | Balance, fees, addresses | `ticker()`, `my_balance()`, `send_raw_tx()` | +| `SwapOps` | V1 HTLC operations | `send_maker_payment()`, `validate_taker_payment()` | +| `CommonSwapOpsV2` | Shared V2 swap operations | `derive_htlc_pubkey_v2()` | +| `MakerCoinSwapOpsV2` | V2 maker operations | `send_maker_payment_v2()`, `refund_maker_payment_v2()` | +| `TakerCoinSwapOpsV2` | V2 taker operations | `send_taker_funding()`, `sign_and_send_taker_funding_spend()` | +| `MakerNftSwapOpsV2` | V2 NFT maker operations | NFT-specific swap methods | +| `WatcherOps` | Third-party spend/refund | `watcher_validate_taker_fee()` | +| `HDWalletCoinOps` | HD wallet coin operations | `derive_address()`, `derive_addresses()` | +| `CoinWithPrivKeyPolicy` | Key policy access | `priv_key_policy()` | + +*Additional specialized traits exist for RPC transport, EIP-1559, balance updates, etc.* + +## Adding a New Coin + +### 1. Choose Base Implementation + +| Type | Base | Examples | +|------|------|----------| +| UTXO | `UtxoStandardCoin` | BTC, LTC, KMD | +| UTXO (Qtum) | `QtumCoin` | QTUM | +| UTXO (BCH) | `BchCoin` | BCH (with SLP support) | +| SLP Token | `SlpToken` | SLP tokens on BCH | +| QRC20 | `Qrc20Coin` | QRC20 tokens on Qtum | +| EVM | `EthCoin` | ETH, MATIC, BNB | +| TRON | `EthCoin` | TRX (wallet-only, via ChainSpec::Tron) | +| ERC20/NFT | `EthCoin` (token) | USDT, WBTC, NFTs | +| Tendermint | `TendermintCoin` | ATOM, OSMO | +| Tendermint Token | `TendermintToken` | IBC tokens | +| Lightning | `LightningCoin` | BTC Lightning (native only) | +| Solana | `SolanaCoin` | SOL | +| SPL Token | `SolanaToken` | SPL tokens on Solana | +| Zcash-based | `ZCoin` | ZEC, ARRR (Pirate) | +| Sia | `SiaCoin` | SIA | + +### 2. Implement Traits + +Required for `MmCoin` (trait bound: `SwapOps + WatcherOps + MarketCoinOps`): +```rust +impl MarketCoinOps for MyCoin { ... } // Addresses, balance, tx broadcast, signing +impl SwapOps for MyCoin { ... } // HTLC operations +impl WatcherOps for MyCoin { ... } // Default impls available +impl MmCoin for MyCoin { ... } // Withdraw, history, fees, confirmations +``` + +See Core Traits table and existing implementations for additional traits (V2 swaps, HD wallet, NFT). + +### 3. Add to MmCoinEnum + +In `lp_coins.rs`: +```rust +pub enum MmCoinEnum { + // ...existing + MyCoinVariant(MyCoin), +} +impl From for MmCoinEnum { ... } +``` + +### 4. Add Activation + +See `coins_activation/AGENTS.md`. Activation traits (task-based `Init*` traits take precedence as they support all wallet types): +- Platform: `PlatformCoinWithTokensActivationOps` +- Standalone: `InitStandaloneCoinActivationOps` (preferred), `StandaloneCoinActivationOps` +- Token: `InitTokenActivationOps` (preferred), `TokenActivationOps` + +## Protocol Specifics + +### UTXO (utxo.rs, utxo/) +- `UtxoCoinConf`: Network params, address prefixes +- `UtxoRpcClientEnum`: Electrum or Native RPC +- SPV validation via `mm2_bitcoin/spv_validation` +- Address formats: Standard, Segwit, CashAddress +- WalletConnect: P2PKH/P2WPKH/P2SH signing via PSBT (`utxo/wallet_connect.rs`) + +### EVM (eth.rs, eth/) +- `ChainSpec`: `Evm { chain_id }` or `Tron { network }` - determines chain behavior +- `EthCoinType`: `Eth`, `Erc20 { token_addr }`, `Nft` +- `EthPrivKeyPolicy`: Iguana/HD/Trezor/MetaMask/WalletConnect +- Gas constants: `ETH_PAYMENT = 65_000`, `ERC20_PAYMENT = 150_000` +- NFT swap support via `SwapV2Contracts` + +### TRON (eth/tron/) +- Reuses `EthCoin` with `ChainSpec::Tron { network }` +- `TronAddress`: Base58Check encoding (`T...` format) +- `TronApiClient`: HTTP RPC client (native + WASM) +- `ChainRpcClient::Tron`: Implements `ChainRpcOps` for balance, block, address-used checks +- Wallet-only mode (no swap contracts yet) +- HD activation via `enable_eth_with_tokens` / `task::enable_eth::*` + +### Tendermint (tendermint/) +- IBC token transfers +- Staking (experimental namespace) +- HTLC via Iris/Nucleus modules + +### Zcash (z_coin.rs) +- Shielded transactions (Sapling proofs) +- Lightwalletd or Electrum data source + +### Lightning (lightning.rs) +- Native only, uses rust-lightning (LDK) +- Channel management, invoice payments +- Swap support via HTLC invoices + +### Sia (siacoin.rs) +- Minimal implementation +- Basic wallet operations, HTLC swaps +- Missing: watchers, tx history v2, dynamic fees + +### Solana (solana/) +- Work in progress +- Basic wallet operations implemented +- Missing: swap operations, most MmCoin methods + +## HD Wallet Integration + +Key traits in `hd_wallet/`: +- `HDWalletCoinOps`: Coin-level derivation +- `HDWalletOps`: Wallet operations (accounts, gap limit) +- `HDAccountOps`: Account management +- `HDAddressOps`: Address operations + +## Interactions + +| Crate | Usage | +|-------|-------| +| **mm2_main** | Swap engines call coin traits | +| **crypto** | `PrivKeyBuildPolicy` for key derivation | +| **coins_activation** | Initialization flows | +| **common** | Time utilities, DEX fee constants | +| **mm2_bitcoin** | UTXO primitives (chain, keys, script, serialization) | +| **mm2_number** | MmNumber for amounts | +| **mm2_core** | MmArc context access, CoinsContext storage | +| **mm2_err_handle** | MmError framework | +| **trezor** | Hardware wallet signing | +| **kdf_walletconnect** | WalletConnect v2 signing (EVM + UTXO) | +| **utxo_signer** | UTXO transaction signing (sub-crate) | +| **mm2_net** | HTTP transport for RPC calls | +| **mm2_db** | IndexedDB storage (WASM) | +| **db_common** | SQLite storage (native) | + +## Key Invariants + +- Coins must be activated before use (`lp_coinfind_or_err`) +- Token activation requires platform coin first +- HD mode requires `GlobalHDAccountCtx` in crypto context + +## Common Pitfalls + +*Add pitfalls here when the same issue is encountered multiple times.* diff --git a/mm2src/coins/Cargo.toml b/mm2src/coins/Cargo.toml index 0a02126de3..6b185eb443 100644 --- a/mm2src/coins/Cargo.toml +++ b/mm2src/coins/Cargo.toml @@ -5,15 +5,24 @@ edition = "2018" [features] zhtlc-native-tests = [] -default = [] +tron-network-tests = [] +default = ["utxo-walletconnect"] run-docker-tests = [] for-tests = ["dep:mocktopus"] new-db-arch = ["mm2_core/new-db-arch"] +# ETH/ERC20 watcher support - disabled by default because ETH watchers are +# unstable and not completed yet +enable-eth-watchers = [] + # Temporary feature for implementing IBC wrap/unwrap mechanism and will be removed # once we consider it as stable. ibc-routing-for-swaps = [] +# UTXO WalletConnect support - requires bitcoin crate and chain/ext-bitcoin for PSBT signing +# TODO: Remove this feature once secp256k1 versions are unified (see mm2_bitcoin/chain/Cargo.toml). +utxo-walletconnect = ["bitcoin", "chain/ext-bitcoin"] + [lib] path = "lp_coins.rs" doctest = false @@ -23,6 +32,7 @@ async-std = { workspace = true, features = ["unstable"] } async-trait.workspace = true base64.workspace = true bip32.workspace = true +bitcoin = { workspace = true, optional = true } bitcoin_hashes.workspace = true bitcrypto = { path = "../mm2_bitcoin/crypto" } blake2b_simd = { workspace = true } @@ -30,7 +40,7 @@ bs58.workspace = true byteorder.workspace = true bytes.workspace = true cfg-if.workspace = true -chain = { path = "../mm2_bitcoin/chain" } +chain = { path = "../mm2_bitcoin/chain", default-features = false } chrono = { workspace = true, "features" = ["serde"] } common = { path = "../common" } compatible-time.workspace = true @@ -68,11 +78,11 @@ mm2_event_stream = { path = "../mm2_event_stream" } mm2_io = { path = "../mm2_io" } mm2_metrics = { path = "../mm2_metrics" } mm2_net = { path = "../mm2_net" } -mm2_number = { path = "../mm2_number"} +mm2_number = { path = "../mm2_number" } mm2_p2p = { path = "../mm2_p2p", default-features = false } mm2_rpc = { path = "../mm2_rpc" } mm2_state_machine = { path = "../mm2_state_machine" } -mocktopus = { workspace = true, optional = true } +mocktopus = { workspace = true, optional = true } num-traits.workspace = true parking_lot = { workspace = true } primitives = { path = "../mm2_bitcoin/primitives" } @@ -102,6 +112,9 @@ sha3.workspace = true utxo_signer = { path = "utxo_signer" } # using the same version as cosmrs tendermint-rpc.workspace = true +tokio-tungstenite-wasm = { workspace = true, features = [ + "rustls-tls-native-roots", +] } thiserror.workspace = true url.workspace = true uuid.workspace = true @@ -135,18 +148,43 @@ blake2b_simd.workspace = true ff.workspace = true futures-util.workspace = true jubjub.workspace = true +# TODO: Removing this causes `wasm-pack` to fail when starting a web session. +# Consider PR to futures-ticker to add wasm-bindgen feature, or check if our libp2p fork +# (KomodoPlatform/rust-libp2p k-0.52.12) can be updated - upstream libp2p now uses web-time. +# +# Why needed: Forces `wasm-bindgen` feature on transitive `instant` dep. +# - futures-ticker: Uses `#[cfg(target_family = "wasm")] use instant::Instant;` without features +# - libp2p-core (our fork): Also depends on instant +# Without wasm-bindgen: `instant` declares `extern "C" { fn now() -> f64; }` which generates +# `(import "env" "now" ...)` in WASM. wasm-bindgen's JS glue handles `wbg` imports but passes +# `env` imports through. In browser ESM, this becomes `import { now } from "env"` - a bare +# specifier that browsers reject: "TypeError: The specifier 'env' was a bare specifier..." +# With wasm-bindgen: `instant` uses js-sys `performance.now()` which works in browsers. +instant = { version = "0.1.12", features = ["wasm-bindgen"] } js-sys.workspace = true mm2_db = { path = "../mm2_db" } mm2_metamask = { path = "../mm2_metamask" } mm2_test_helpers = { path = "../mm2_test_helpers" } time = { workspace = true, features = ["wasm-bindgen"] } timed-map = { workspace = true, features = ["rustc-hash", "wasm"] } -tonic = { workspace = true, default-features = false, features = ["prost", "codegen", "gzip"] } +tonic = { workspace = true, default-features = false, features = [ + "prost", + "codegen", + "gzip", +] } tower-service.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true wasm-bindgen-test.workspace = true -web-sys = { workspace = true, features = ["console", "Headers", "Request", "RequestInit", "RequestMode", "Response", "Window"] } +web-sys = { workspace = true, features = [ + "console", + "Headers", + "Request", + "RequestInit", + "RequestMode", + "Response", + "Window", +] } zcash_proofs = { workspace = true, features = ["local-prover"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] @@ -163,7 +201,13 @@ secp256k1v24.workspace = true timed-map = { workspace = true, features = ["rustc-hash"] } tokio.workspace = true tokio-rustls.workspace = true -tonic = { workspace = true, features = ["codegen", "prost", "gzip", "tls", "tls-webpki-roots"] } +tonic = { workspace = true, features = [ + "codegen", + "prost", + "gzip", + "tls", + "tls-webpki-roots", +] } zcash_client_sqlite.workspace = true zcash_proofs = { workspace = true, features = ["local-prover", "multicore"] } diff --git a/mm2src/coins/coin_errors.rs b/mm2src/coins/coin_errors.rs index d71eb281ac..8e9803972b 100644 --- a/mm2src/coins/coin_errors.rs +++ b/mm2src/coins/coin_errors.rs @@ -1,7 +1,8 @@ use crate::eth::eth_swap_v2::{PrepareTxDataError, ValidatePaymentV2Err}; use crate::eth::nft_swap_v2::errors::{Erc721FunctionError, HtlcParamsError}; -use crate::eth::{EthAssocTypesError, EthNftAssocTypesError, Web3RpcError}; -use crate::{utxo::rpc_clients::UtxoRpcError, NumConversError, UnexpectedDerivationMethod}; +use crate::eth::{format_remote_error, EthAssocTypesError, EthNftAssocTypesError, Web3RpcError}; +use crate::utxo::rpc_clients::UtxoRpcError; +use crate::{NumConversError, UnexpectedDerivationMethod}; use derive_more::Display; use enum_derives::EnumFromStringify; use futures01::Future; @@ -76,15 +77,17 @@ impl From for ValidatePaymentError { impl From for ValidatePaymentError { fn from(e: Web3RpcError) -> Self { match e { - Web3RpcError::Transport(tr) => ValidatePaymentError::Transport(tr), + Web3RpcError::Transport(tr) | Web3RpcError::Timeout(tr) | Web3RpcError::BadResponse(tr) => { + ValidatePaymentError::Transport(tr) + }, Web3RpcError::InvalidResponse(resp) => ValidatePaymentError::InvalidRpcResponse(resp), + Web3RpcError::RemoteError { code, message } => { + ValidatePaymentError::Transport(format_remote_error(code, message)) + }, Web3RpcError::Internal(internal) - | Web3RpcError::Timeout(internal) | Web3RpcError::NumConversError(internal) | Web3RpcError::InvalidGasApiConfig(internal) => ValidatePaymentError::InternalError(internal), - Web3RpcError::NftProtocolNotSupported => { - ValidatePaymentError::ProtocolNotSupported("Nft protocol is not supported".to_string()) - }, + Web3RpcError::ProtocolNotSupported(e) => ValidatePaymentError::ProtocolNotSupported(e), Web3RpcError::NoSuchCoin { .. } => ValidatePaymentError::InternalError(e.to_string()), } } diff --git a/mm2src/coins/eth.rs b/mm2src/coins/eth.rs index 7a9349d3f2..905e4f5783 100644 --- a/mm2src/coins/eth.rs +++ b/mm2src/coins/eth.rs @@ -22,7 +22,7 @@ // use self::wallet_connect::{send_transaction_with_walletconnect, WcEthTxParams}; use super::eth::Action::{Call, Create}; -use super::watcher_common::{validate_watcher_reward, REWARD_GAS_AMOUNT}; +use super::watcher_common::{validate_watcher_reward, REWARD_GAS_AMOUNT, REWARD_OVERPAY_FACTOR}; use super::*; use crate::coin_balance::{ EnableCoinBalanceError, EnabledCoinBalanceParams, HDAccountBalance, HDAddressBalance, HDWalletBalance, @@ -34,6 +34,7 @@ use crate::hd_wallet::{ DisplayAddress, HDAccountOps, HDCoinAddress, HDCoinWithdrawOps, HDConfirmAddress, HDPathAccountToAddressId, HDWalletCoinOps, HDXPubExtractor, }; +#[cfg(feature = "enable-eth-watchers")] use crate::lp_price::get_base_price_in_rel; use crate::nft::nft_errors::ParseContractTypeError; use crate::nft::nft_structs::{ @@ -60,10 +61,13 @@ use crate::{ coin_balance, scan_for_new_addresses_impl, BalanceResult, CoinWithDerivationMethod, DerivationMethod, DexFee, Eip1559Ops, GasPriceRpcParam, MakerNftSwapOpsV2, ParseCoinAssocTypes, ParseNftAssocTypes, PrivKeyPolicy, RpcCommonOps, SendNftMakerPaymentArgs, SpendNftMakerPaymentArgs, ToBytes, ValidateNftMakerPaymentArgs, - ValidateWatcherSpendInput, WatcherSpendType, }; +#[cfg(feature = "enable-eth-watchers")] +use crate::{ValidateWatcherSpendInput, WatcherSpendType}; use async_trait::async_trait; -use bitcrypto::{dhash160, keccak256, ripemd160, sha256}; +#[cfg(feature = "enable-eth-watchers")] +use bitcrypto::dhash160; +use bitcrypto::{keccak256, ripemd160, sha256}; use common::custom_futures::repeatable::{Ready, Retry, RetryOnError}; use common::custom_futures::timeout::FutureTimerExt; use common::executor::{ @@ -95,6 +99,7 @@ use futures01::Future; use http::Uri; use kdf_walletconnect::{WalletConnectCtx, WalletConnectOps}; use mm2_core::mm_ctx::{MmArc, MmWeak}; +#[cfg(feature = "enable-eth-watchers")] use mm2_number::bigdecimal_custom::CheckedDivision; use mm2_number::{BigDecimal, BigUint, MmNumber}; use num_traits::FromPrimitive; @@ -133,16 +138,19 @@ use super::{ PaymentInstructions, PaymentInstructionsErr, PrivKeyBuildPolicy, PrivKeyPolicyNotAllowed, RawTransactionError, RawTransactionFut, RawTransactionRequest, RawTransactionRes, RawTransactionResult, RefundPaymentArgs, RewardTarget, RpcClientType, RpcTransportEventHandler, RpcTransportEventHandlerShared, SearchForSwapTxSpendInput, - SendMakerPaymentSpendPreimageInput, SendPaymentArgs, SignEthTransactionParams, SignRawTransactionEnum, - SignRawTransactionRequest, SignatureError, SignatureResult, SpendPaymentArgs, SwapGasFeePolicy, SwapOps, TradeFee, - TradePreimageError, TradePreimageFut, TradePreimageResult, TradePreimageValue, Transaction, TransactionDetails, - TransactionEnum, TransactionErr, TransactionFut, TransactionType, TxMarshalingErr, UnexpectedDerivationMethod, - ValidateAddressResult, ValidateFeeArgs, ValidateInstructionsErr, ValidateOtherPubKeyErr, ValidatePaymentError, - ValidatePaymentFut, ValidatePaymentInput, VerificationError, VerificationResult, WaitForHTLCTxSpendArgs, - WatcherOps, WatcherReward, WatcherRewardError, WatcherSearchForSwapTxSpendInput, WatcherValidatePaymentInput, - WatcherValidateTakerFeeInput, WeakSpawner, WithdrawError, WithdrawFee, WithdrawFut, WithdrawRequest, - WithdrawResult, EARLY_CONFIRMATION_ERR_LOG, INVALID_CONTRACT_ADDRESS_ERR_LOG, INVALID_PAYMENT_STATE_ERR_LOG, - INVALID_RECEIVER_ERR_LOG, INVALID_SENDER_ERR_LOG, INVALID_SWAP_ID_ERR_LOG, + SendPaymentArgs, SignEthTransactionParams, SignRawTransactionEnum, SignRawTransactionRequest, SignatureError, + SignatureResult, SpendPaymentArgs, SwapGasFeePolicy, SwapOps, TradeFee, TradePreimageError, TradePreimageFut, + TradePreimageResult, TradePreimageValue, Transaction, TransactionDetails, TransactionEnum, TransactionErr, + TransactionType, TxMarshalingErr, UnexpectedDerivationMethod, ValidateAddressResult, ValidateFeeArgs, + ValidateInstructionsErr, ValidateOtherPubKeyErr, ValidatePaymentError, ValidatePaymentFut, ValidatePaymentInput, + VerificationError, VerificationResult, WaitForHTLCTxSpendArgs, WatcherOps, WatcherRewardError, WeakSpawner, + WithdrawError, WithdrawFee, WithdrawFut, WithdrawRequest, WithdrawResult, EARLY_CONFIRMATION_ERR_LOG, + INVALID_CONTRACT_ADDRESS_ERR_LOG, INVALID_RECEIVER_ERR_LOG, INVALID_SENDER_ERR_LOG, +}; +#[cfg(feature = "enable-eth-watchers")] +use crate::{ + SendMakerPaymentSpendPreimageInput, TransactionFut, WatcherReward, WatcherSearchForSwapTxSpendInput, + WatcherValidatePaymentInput, WatcherValidateTakerFeeInput, INVALID_PAYMENT_STATE_ERR_LOG, INVALID_SWAP_ID_ERR_LOG, }; #[cfg(test)] pub(crate) use eth_utils::display_u256_with_decimal_point; @@ -173,12 +181,15 @@ pub mod wallet_connect; mod web3_transport; use web3_transport::{http_transport::HttpTransportNode, Web3Transport}; +pub mod chain_address; +pub use chain_address::ChainTaggedAddress; + pub mod eth_hd_wallet; -use eth_hd_wallet::EthHDWallet; +pub use eth_hd_wallet::EthHDWallet; #[path = "eth/v2_activation.rs"] pub mod v2_activation; -use v2_activation::{build_address_and_priv_key_policy, EthActivationV2Error}; +use v2_activation::{build_address_and_priv_key_policy_evm_legacy, EthActivationV2Error}; mod eth_withdraw; use eth_withdraw::{EthWithdraw, InitEthWithdraw, StandardEthWithdraw}; @@ -190,12 +201,17 @@ use fee_estimation::eip1559::{ }; pub mod erc20; -use erc20::get_token_decimals; + pub(crate) mod eth_swap_v2; use eth_swap_v2::{extract_id_from_tx_data, EthPaymentType, PaymentMethod, SpendTxSearchParams}; pub mod eth_utils; + pub mod tron; +use tron::{normalize_tron_raw_tx_hex, validate_tron_raw_tx_len, TronAddress}; + +pub mod chain_rpc; +use self::chain_rpc::ChainRpcOps; /// Default timeout to wait for eth rpc request to complete pub(crate) const ETH_RPC_REQUEST_TIMEOUT_S: Duration = Duration::from_secs(30); @@ -470,7 +486,7 @@ impl EthGasLimitV2 { }; Ok(gas_limit) }, - EthCoinType::Nft { .. } => Err("NFT protocol is not supported for ETH and ERC20 Swaps".to_string()), + EthCoinType::Nft { .. } => Err(format!("{} is not supported for ETH and ERC20 Swaps", coin_type)), } } @@ -576,7 +592,7 @@ lazy_static! { pub static ref NFT_MAKER_SWAP_V2: Contract = Contract::load(NFT_MAKER_SWAP_V2_ABI.as_bytes()).unwrap(); } -pub type EthDerivationMethod = DerivationMethod; +pub type EthDerivationMethod = DerivationMethod; pub type Web3RpcFut = Box> + Send>; pub type Web3RpcResult = Result>; type EthPrivKeyPolicy = PrivKeyPolicy; @@ -620,23 +636,61 @@ type GasDetails = (U256, PayForGasOption); pub enum Web3RpcError { #[display(fmt = "Transport: {_0}")] Transport(String), + /// Node replied with malformed/invalid/unexpected payload (schema mismatch, bad JSON, etc.). + /// Retryable - another node may respond correctly. + #[display(fmt = "Bad response: {_0}")] + BadResponse(String), #[display(fmt = "Invalid response: {_0}")] InvalidResponse(String), #[display(fmt = "Timeout: {_0}")] Timeout(String), + /// Deterministic, well-formed remote rejection (e.g., TRON's CONTRACT_VALIDATE_ERROR). + /// Non-retryable - another node would produce the same rejection. + #[display(fmt = "Remote error: {}", message)] + RemoteError { code: Option, message: String }, #[from_stringify("serde_json::Error")] #[display(fmt = "Internal: {_0}")] Internal(String), #[display(fmt = "Invalid gas api provider config: {_0}")] InvalidGasApiConfig(String), - #[display(fmt = "Nft Protocol is not supported yet!")] - NftProtocolNotSupported, + #[display(fmt = "Protocol not supported: {_0}")] + ProtocolNotSupported(String), #[display(fmt = "Number conversion: {_0}")] NumConversError(String), #[display(fmt = "No such coin {}", coin)] NoSuchCoin { coin: String }, } +impl Web3RpcError { + /// Returns `true` if the error is transient and the request may succeed on a different node. + /// + /// Retryable errors: + /// - `Transport`: Network failures, connection errors + /// - `Timeout`: Request timed out + /// - `BadResponse`: Node sent malformed/unexpected data (faulty node, try another) + /// + /// Non-retryable errors: + /// - `InvalidResponse`: Legacy, used by EVM for web3 RPC errors + /// - `RemoteError`: Deterministic rejection (e.g., TRON CONTRACT_VALIDATE_ERROR) + /// - `Internal`: Programming errors + /// - Others: Configuration/protocol errors + pub fn is_retryable(&self) -> bool { + matches!( + self, + Web3RpcError::Transport(_) | Web3RpcError::Timeout(_) | Web3RpcError::BadResponse(_) + ) + } +} + +/// Formats a RemoteError's code and message into a single string. +/// Used when converting RemoteError to error types that only have a String field. +pub fn format_remote_error(code: Option, message: String) -> String { + match code { + Some(c) => format!("{c}: {message}"), + None => message, + } +} + impl From for Web3RpcError { fn from(e: web3::Error) -> Self { let error_str = e.to_string(); @@ -653,17 +707,23 @@ impl From for Web3RpcError { } impl From for RawTransactionError { + // TODO: BadResponse (malformed JSON/schema mismatch) is collapsed into Transport here. + // This preserves retryability but loses semantic distinction for observability/telemetry. + // Consider adding a BadResponse variant to downstream errors if better debugging is needed. fn from(e: Web3RpcError) -> Self { match e { - Web3RpcError::Transport(tr) | Web3RpcError::InvalidResponse(tr) => RawTransactionError::Transport(tr), + Web3RpcError::Transport(tr) + | Web3RpcError::Timeout(tr) + | Web3RpcError::BadResponse(tr) + | Web3RpcError::InvalidResponse(tr) => RawTransactionError::Transport(tr), + Web3RpcError::RemoteError { code, message } => { + RawTransactionError::Transport(format_remote_error(code, message)) + }, Web3RpcError::Internal(internal) - | Web3RpcError::Timeout(internal) | Web3RpcError::NumConversError(internal) - | Web3RpcError::InvalidGasApiConfig(internal) => RawTransactionError::InternalError(internal), + | Web3RpcError::InvalidGasApiConfig(internal) + | Web3RpcError::ProtocolNotSupported(internal) => RawTransactionError::InternalError(internal), Web3RpcError::NoSuchCoin { coin } => RawTransactionError::NoSuchCoin { coin }, - Web3RpcError::NftProtocolNotSupported => { - RawTransactionError::InternalError("Nft Protocol is not supported yet!".to_string()) - }, } } } @@ -723,13 +783,16 @@ impl From for WithdrawError { impl From for WithdrawError { fn from(e: Web3RpcError) -> Self { match e { - Web3RpcError::Transport(err) | Web3RpcError::InvalidResponse(err) => WithdrawError::Transport(err), + Web3RpcError::Transport(err) + | Web3RpcError::Timeout(err) + | Web3RpcError::BadResponse(err) + | Web3RpcError::InvalidResponse(err) => WithdrawError::Transport(err), + Web3RpcError::RemoteError { code, message } => WithdrawError::Transport(format_remote_error(code, message)), Web3RpcError::Internal(internal) - | Web3RpcError::Timeout(internal) | Web3RpcError::NumConversError(internal) | Web3RpcError::InvalidGasApiConfig(internal) => WithdrawError::InternalError(internal), + Web3RpcError::ProtocolNotSupported(e) => WithdrawError::ProtocolNotSupported(e), Web3RpcError::NoSuchCoin { coin } => WithdrawError::NoSuchCoin { coin }, - Web3RpcError::NftProtocolNotSupported => WithdrawError::NftProtocolNotSupported, } } } @@ -749,13 +812,18 @@ impl From for TradePreimageError { impl From for TradePreimageError { fn from(e: Web3RpcError) -> Self { match e { - Web3RpcError::Transport(err) | Web3RpcError::InvalidResponse(err) => TradePreimageError::Transport(err), + Web3RpcError::Transport(err) + | Web3RpcError::Timeout(err) + | Web3RpcError::BadResponse(err) + | Web3RpcError::InvalidResponse(err) => TradePreimageError::Transport(err), + Web3RpcError::RemoteError { code, message } => { + TradePreimageError::Transport(format_remote_error(code, message)) + }, Web3RpcError::Internal(internal) - | Web3RpcError::Timeout(internal) | Web3RpcError::NumConversError(internal) | Web3RpcError::InvalidGasApiConfig(internal) => TradePreimageError::InternalError(internal), + Web3RpcError::ProtocolNotSupported(e) => TradePreimageError::ProtocolNotSupported(e), Web3RpcError::NoSuchCoin { coin } => TradePreimageError::NoSuchCoin { coin }, - Web3RpcError::NftProtocolNotSupported => TradePreimageError::NftProtocolNotSupported, } } } @@ -785,15 +853,16 @@ impl From for BalanceError { impl From for BalanceError { fn from(e: Web3RpcError) -> Self { match e { - Web3RpcError::Transport(tr) | Web3RpcError::InvalidResponse(tr) => BalanceError::Transport(tr), + Web3RpcError::Transport(tr) + | Web3RpcError::Timeout(tr) + | Web3RpcError::BadResponse(tr) + | Web3RpcError::InvalidResponse(tr) => BalanceError::Transport(tr), + Web3RpcError::RemoteError { code, message } => BalanceError::Transport(format_remote_error(code, message)), Web3RpcError::Internal(internal) - | Web3RpcError::Timeout(internal) | Web3RpcError::NumConversError(internal) - | Web3RpcError::InvalidGasApiConfig(internal) => BalanceError::Internal(internal), + | Web3RpcError::InvalidGasApiConfig(internal) + | Web3RpcError::ProtocolNotSupported(internal) => BalanceError::Internal(internal), Web3RpcError::NoSuchCoin { coin } => BalanceError::NoSuchCoin { coin }, - Web3RpcError::NftProtocolNotSupported => { - BalanceError::Internal("Nft Protocol is not supported yet!".to_string()) - }, } } } @@ -844,6 +913,39 @@ pub enum ChainSpec { Tron { network: tron::Network }, } +/// Lightweight chain discriminator for formatting decisions. +/// Derived from ChainSpec but intentionally drops chain-specific details. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ChainFamily { + Evm, + Tron, +} + +impl From<&ChainSpec> for ChainFamily { + fn from(spec: &ChainSpec) -> Self { + match spec { + ChainSpec::Evm { .. } => ChainFamily::Evm, + ChainSpec::Tron { .. } => ChainFamily::Tron, + } + } +} + +impl ChainFamily { + /// Canonical address formatter. This is the SINGLE source of truth for formatting. + /// + /// - `Evm` β†’ EIP-55 mixed-case checksum format (`0xAbCd...`) + /// - `Tron` β†’ Base58Check format (`T...`) + /// + /// All other formatting methods (`ChainTaggedAddress::display_address`, + /// `EthCoin::format_raw_address`) MUST delegate to this method. + pub fn format(self, raw: Address) -> String { + match self { + ChainFamily::Evm => checksum_address(&raw.addr_to_string()), + ChainFamily::Tron => tron::TronAddress::from(raw).to_base58(), + } + } +} + impl ChainSpec { pub fn chain_id(&self) -> Option { match self { @@ -862,7 +964,8 @@ impl ChainSpec { #[derive(Clone, Debug, PartialEq, Eq)] pub enum EthCoinType { - /// Ethereum itself or it's forks: ETC/others + /// Ethereum itself or it's forks: ETC/others. + /// This type is also used for EVM compatible protocols like TRON. Eth, /// ERC20 token with smart contract address /// https://github.com/ethereum/EIPs/blob/master/EIPS/eip-20.md @@ -875,6 +978,18 @@ pub enum EthCoinType { }, } +impl fmt::Display for EthCoinType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EthCoinType::Eth => write!(f, "ETH"), + EthCoinType::Erc20 { platform, token_addr } => { + write!(f, "ERC20(platform: {}, token: {:#x})", platform, token_addr) + }, + EthCoinType::Nft { platform } => write!(f, "NFT on {}", platform), + } + } +} + /// An alternative to `crate::PrivKeyBuildPolicy`, typical only for ETH coin. pub enum EthPrivKeyBuildPolicy { IguanaPrivKey(IguanaPrivKey), @@ -934,10 +1049,11 @@ pub struct EthCoinImpl { fallback_swap_contract: Option
, contract_supports_watchers: bool, web3_instances: AsyncMutex>, + /// Chain-specific RPC client (TRON API for TRON chains, EVM RPC for future). + pub(crate) rpc_client: Option, decimals: u8, history_sync_state: Mutex, required_confirmations: AtomicU64, - #[cfg_attr(feature = "run-docker-tests", allow(dead_code))] swap_gas_fee_policy: Mutex, max_eth_tx_type: Option, gas_price_adjust: Option, @@ -1023,9 +1139,30 @@ macro_rules! tx_type_from_pay_for_gas_option { } impl EthCoinImpl { + // TODO: Post-MVP (Phase 4) - This accessor pattern will evolve: + // 1. When `ChainRpcClient::Evm` is implemented, add `evm_rpc() -> Option<&EvmRpcClient>` + // 2. Eventually unify via `ChainRpcClient` implementing `ChainRpcOps` directly with + // dispatch enums (`ChainAddress`, `ChainBalance`), eliminating variant-specific accessors. + // See `docs/plans/chain-rpc-client-refactor.md` for the full migration path. + + /// Returns a reference to the TRON API client if this coin is a TRON chain. + /// + /// Use this instead of pattern-matching on `rpc_client` to avoid spreading + /// chain-specific branching across the codebase. + pub fn tron_rpc(&self) -> Option<&tron::TronApiClient> { + match &self.rpc_client { + Some(chain_rpc::ChainRpcClient::Tron(tron)) => Some(tron), + _ => None, + } + } + + // NOTE: These trace/event persistence methods use EVM-specific address formatting. + // For TRON support, transaction history will likely be implemented via ChainRpcClient + // or a dedicated trait abstraction rather than modifying these methods. #[cfg(not(target_arch = "wasm32"))] fn eth_traces_path(&self, ctx: &MmArc, my_address: Address) -> PathBuf { - ctx.address_dir(&my_address.display_address()) + // EVM-only path function - uses Evm formatting explicitly + ctx.address_dir(&ChainFamily::Evm.format(my_address)) .join("TRANSACTIONS") .join(format!("{}_{:#02x}_trace.json", self.ticker, my_address)) } @@ -1066,7 +1203,8 @@ impl EthCoinImpl { #[cfg(not(target_arch = "wasm32"))] fn erc20_events_path(&self, ctx: &MmArc, my_address: Address) -> PathBuf { - ctx.address_dir(&my_address.display_address()) + // EVM-only path function - uses Evm formatting explicitly + ctx.address_dir(&ChainFamily::Evm.format(my_address)) .join("TRANSACTIONS") .join(format!("{}_{:#02x}_events.json", self.ticker, my_address)) } @@ -1124,9 +1262,15 @@ impl EthCoinImpl { sha256(&input).to_vec() } - /// Try to parse address from string. - pub fn address_from_str(&self, address: &str) -> Result { - Ok(try_s!(valid_addr_from_str(address))) + /// Parses an address string using the coin's chain context. + /// + /// - **EVM**: Accepts `0x...` with EIP-55 checksum validation + /// - **TRON**: Accepts Base58 (`T...`) or hex (`41...` / `0x41...`) + /// + /// Delegates to `ChainTaggedAddress::from_str_with_family`. + pub fn address_from_str(&self, address: &str) -> Result { + let family = ChainFamily::from(&self.chain_spec); + ChainTaggedAddress::from_str_with_family(address, family) } pub fn erc20_token_address(&self) -> Option
{ @@ -1217,7 +1361,8 @@ pub async fn withdraw_erc1155(ctx: MmArc, withdraw_type: WithdrawErc1155) -> Wit }); } - let my_address = eth_coin.derivation_method.single_addr_or_err().await.map_mm_err()?; + let my_address_tagged = eth_coin.derivation_method.single_addr_or_err().await.map_mm_err()?; + let my_address = my_address_tagged.inner(); let (eth_value, data, call_addr, fee_coin) = match eth_coin.coin_type { EthCoinType::Eth => { let function = ERC1155_CONTRACT.function("safeTransferFrom")?; @@ -1241,7 +1386,12 @@ pub async fn withdraw_erc1155(ctx: MmArc, withdraw_type: WithdrawErc1155) -> Wit "Erc20 coin type doesnt support withdraw nft".to_owned(), )) }, - EthCoinType::Nft { .. } => return MmError::err(WithdrawError::NftProtocolNotSupported), + EthCoinType::Nft { .. } => { + return MmError::err(WithdrawError::ProtocolNotSupported(format!( + "{} protocol is not supported", + eth_coin.coin_type + ))) + }, }; let (gas, pay_for_gas_option) = get_eth_gas_details_from_withdraw_fee( ð_coin, @@ -1268,15 +1418,11 @@ pub async fn withdraw_erc1155(ctx: MmArc, withdraw_type: WithdrawErc1155) -> Wit if !eth_coin.is_tx_type_supported(&tx_type) { return MmError::err(WithdrawError::TxTypeNotSupported); } - let chain_id = match eth_coin.chain_spec { - ChainSpec::Evm { chain_id } => chain_id, - // Todo: Add support for Tron NFTs - ChainSpec::Tron { .. } => { - return MmError::err(WithdrawError::InternalError( - "Tron is not supported for withdraw_erc1155 yet".to_owned(), - )) - }, - }; + // Todo: Add support for Tron NFTs + let chain_id = eth_coin + .chain_spec + .chain_id() + .ok_or_else(|| WithdrawError::InternalError("Tron is not supported for withdraw_erc1155 yet".to_owned()))?; let tx_builder = UnSignedEthTxBuilder::new(tx_type, nonce, gas, Action::Call(call_addr), eth_value, data); let tx_builder = tx_builder_with_pay_for_gas_option(ð_coin, tx_builder, &pay_for_gas_option)?; let tx = tx_builder @@ -1290,7 +1436,7 @@ pub async fn withdraw_erc1155(ctx: MmArc, withdraw_type: WithdrawErc1155) -> Wit Ok(TransactionNftDetails { tx_hex: BytesJson::from(signed_bytes.to_vec()), // TODO: should we return tx_hex 0x-prefixed (everywhere)? tx_hash: format!("{:02x}", signed.tx_hash_as_bytes()), // TODO: add 0x hash (use unified hash format for eth wherever it is returned) - from: vec![my_address.display_address()], + from: vec![my_address_tagged.display_address()], to: vec![withdraw_type.to], contract_type: ContractType::Erc1155, token_address: withdraw_type.token_address, @@ -1307,6 +1453,8 @@ pub async fn withdraw_erc1155(ctx: MmArc, withdraw_type: WithdrawErc1155) -> Wit /// `withdraw_erc721` function returns details of `ERC-721` transaction including tx hex, /// which should be sent to`send_raw_transaction` RPC to broadcast the transaction. +// Todo: NFT support for TRON (TRC-721) is out of MVP scope. When implementing, +// address formatting for `token_owner` in error messages will need chain-aware handling. pub async fn withdraw_erc721(ctx: MmArc, withdraw_type: WithdrawErc721) -> WithdrawNftResult { let coin = lp_coinfind_or_err(&ctx, withdraw_type.chain.to_ticker()) .await @@ -1316,15 +1464,15 @@ pub async fn withdraw_erc721(ctx: MmArc, withdraw_type: WithdrawErc721) -> Withd let token_id_str = &withdraw_type.token_id.to_string(); let token_owner = eth_coin.erc721_owner(token_addr, token_id_str).await.map_mm_err()?; - let my_address = eth_coin.derivation_method.single_addr_or_err().await.map_mm_err()?; - if token_owner != my_address { + let my_address_tagged = eth_coin.derivation_method.single_addr_or_err().await.map_mm_err()?; + if token_owner != my_address_tagged.inner() { return MmError::err(WithdrawError::MyAddressNotNftOwner { - my_address: my_address.display_address(), - token_owner: token_owner.display_address(), + my_address: my_address_tagged.display_address(), + token_owner: eth_coin.format_raw_address(token_owner), }); } - let my_address = eth_coin.derivation_method.single_addr_or_err().await.map_mm_err()?; + let my_address = my_address_tagged.inner(); let (eth_value, data, call_addr, fee_coin) = match eth_coin.coin_type { EthCoinType::Eth => { let function = ERC721_CONTRACT.function("safeTransferFrom")?; @@ -1344,7 +1492,12 @@ pub async fn withdraw_erc721(ctx: MmArc, withdraw_type: WithdrawErc721) -> Withd )) }, // TODO: start to use NFT GLOBAL TOKEN for withdraw - EthCoinType::Nft { .. } => return MmError::err(WithdrawError::NftProtocolNotSupported), + EthCoinType::Nft { .. } => { + return MmError::err(WithdrawError::ProtocolNotSupported(format!( + "{} protocol is not supported", + eth_coin.coin_type + ))) + }, }; let (gas, pay_for_gas_option) = get_eth_gas_details_from_withdraw_fee( ð_coin, @@ -1378,15 +1531,11 @@ pub async fn withdraw_erc721(ctx: MmArc, withdraw_type: WithdrawErc721) -> Withd .build() .map_to_mm(|e| WithdrawError::InternalError(e.to_string()))?; let secret = eth_coin.priv_key_policy.activated_key_or_err().map_mm_err()?.secret(); - let chain_id = match eth_coin.chain_spec { - ChainSpec::Evm { chain_id } => chain_id, - // Todo: Add support for Tron NFTs - ChainSpec::Tron { .. } => { - return MmError::err(WithdrawError::InternalError( - "Tron is not supported for withdraw_erc721 yet".to_owned(), - )) - }, - }; + // Todo: Add support for Tron NFTs + let chain_id = eth_coin + .chain_spec + .chain_id() + .ok_or_else(|| WithdrawError::InternalError("Tron is not supported for withdraw_erc721 yet".to_owned()))?; let signed = tx.sign(secret, Some(chain_id))?; let signed_bytes = rlp::encode(&signed); let fee_details = EthTxFeeDetails::new(gas, pay_for_gas_option, fee_coin).map_mm_err()?; @@ -1394,7 +1543,7 @@ pub async fn withdraw_erc721(ctx: MmArc, withdraw_type: WithdrawErc721) -> Withd Ok(TransactionNftDetails { tx_hex: BytesJson::from(signed_bytes.to_vec()), tx_hash: format!("{:02x}", signed.tx_hash_as_bytes()), // TODO: add 0x hash (use unified hash format for eth wherever it is returned) - from: vec![my_address.display_address()], + from: vec![my_address_tagged.display_address()], to: vec![withdraw_type.to], contract_type: ContractType::Erc721, token_address: withdraw_type.token_address, @@ -1571,14 +1720,8 @@ impl SwapOps for EthCoin { input: SearchForSwapTxSpendInput<'_>, ) -> Result, String> { let swap_contract_address = try_s!(input.swap_contract_address.try_to_address()); - self.search_for_swap_tx_spend( - input.tx, - swap_contract_address, - input.secret_hash, - input.search_from_block, - input.watcher_reward, - ) - .await + self.search_for_swap_tx_spend(input.tx, swap_contract_address, input.search_from_block) + .await } async fn search_for_swap_tx_spend_other( @@ -1586,39 +1729,46 @@ impl SwapOps for EthCoin { input: SearchForSwapTxSpendInput<'_>, ) -> Result, String> { let swap_contract_address = try_s!(input.swap_contract_address.try_to_address()); - self.search_for_swap_tx_spend( - input.tx, - swap_contract_address, - input.secret_hash, - input.search_from_block, - input.watcher_reward, - ) - .await + self.search_for_swap_tx_spend(input.tx, swap_contract_address, input.search_from_block) + .await } - async fn extract_secret( - &self, - _secret_hash: &[u8], - spend_tx: &[u8], - watcher_reward: bool, - ) -> Result<[u8; 32], String> { + async fn extract_secret(&self, _secret_hash: &[u8], spend_tx: &[u8]) -> Result<[u8; 32], String> { let unverified: UnverifiedTransactionWrapper = try_s!(rlp::decode(spend_tx)); - let function_name = get_function_name("receiverSpend", watcher_reward); - let function = try_s!(SWAP_CONTRACT.function(&function_name)); - - // Validate contract call; expected to be receiverSpend. - // https://www.4byte.directory/signatures/?bytes4_signature=02ed292b. - let expected_signature = function.short_signature(); - let actual_signature = &unverified.unsigned().data()[0..4]; - if actual_signature != expected_signature { + let tx_data = unverified.unsigned().data(); + if tx_data.len() < 4 { + return ERR!("Transaction data too short to contain function selector"); + } + let actual_signature = &tx_data[0..4]; + + // Auto-detect which receiverSpend variant was used by matching the function selector. + // Both variants have the secret at index 2, so we can use either for extraction. + // Note: receiverSpendReward may not exist until watcher-compatible contracts are deployed. + let receiver_spend = try_s!(SWAP_CONTRACT.function("receiverSpend")); + let receiver_spend_reward = SWAP_CONTRACT.function("receiverSpendReward").ok(); + + let function = if actual_signature == receiver_spend.short_signature() { + receiver_spend + } else if let Some(reward_func) = receiver_spend_reward.as_ref() { + if actual_signature == reward_func.short_signature() { + reward_func + } else { + return ERR!( + "Transaction is not a receiverSpend call. Expected signature {:?} or {:?}, found {:?}", + receiver_spend.short_signature(), + reward_func.short_signature(), + actual_signature + ); + } + } else { return ERR!( - "Expected 'receiverSpend' contract call signature: {:?}, found {:?}", - expected_signature, + "Transaction is not a receiverSpend call. Expected signature {:?}, found {:?}", + receiver_spend.short_signature(), actual_signature ); }; - let tokens = try_s!(decode_contract_call(function, unverified.unsigned().data())); + let tokens = try_s!(decode_contract_call(function, tx_data)); if tokens.len() < 3 { return ERR!("Invalid arguments in 'receiverSpend' call: {:?}", tokens); } @@ -1752,6 +1902,11 @@ impl SwapOps for EthCoin { } } +// ETH WatcherOps implementation - gated behind `enable-eth-watchers` feature +// because ETH watchers are unstable and not completed yet. +// When disabled, EthCoin uses the default WatcherOps implementation from lp_coins.rs +// which returns "not implemented" errors. +#[cfg(feature = "enable-eth-watchers")] #[async_trait] impl WatcherOps for EthCoin { fn send_maker_payment_spend_preimage(&self, input: SendMakerPaymentSpendPreimageInput) -> TransactionFut { @@ -1932,7 +2087,7 @@ impl WatcherOps for EthCoin { ))); } - let my_address = selfi.derivation_method.single_addr_or_err().await.map_mm_err()?; + let my_address = selfi.derivation_method.single_addr_or_err().await.map_mm_err()?.inner(); let sender_input = get_function_input_data(&decoded, function, 4) .map_to_mm(ValidatePaymentError::TxDeserializationError) .map_mm_err()?; @@ -2065,9 +2220,10 @@ impl WatcherOps for EthCoin { } }, EthCoinType::Nft { .. } => { - return MmError::err(ValidatePaymentError::ProtocolNotSupported( - "Nft protocol is not supported by watchers yet".to_string(), - )) + return MmError::err(ValidatePaymentError::ProtocolNotSupported(format!( + "{} protocol is not supported by watchers yet", + selfi.coin_type + ))) }, } @@ -2335,9 +2491,10 @@ impl WatcherOps for EthCoin { } }, EthCoinType::Nft { .. } => { - return MmError::err(ValidatePaymentError::ProtocolNotSupported( - "Nft protocol is not supported by watchers yet".to_string(), - )) + return MmError::err(ValidatePaymentError::ProtocolNotSupported(format!( + "{} protocol is not supported by watchers yet", + selfi.coin_type + ))) }, } @@ -2357,14 +2514,8 @@ impl WatcherOps for EthCoin { Create => return Err(ERRL!("Invalid payment action: the payment action cannot be create")), }; - self.search_for_swap_tx_spend( - input.tx, - swap_contract_address, - input.secret_hash, - input.search_from_block, - true, - ) - .await + self.search_for_swap_tx_spend(input.tx, swap_contract_address, input.search_from_block) + .await } async fn get_taker_watcher_reward( @@ -2432,9 +2583,10 @@ impl WatcherOps for EthCoin { } }, EthCoinType::Nft { .. } => { - return MmError::err(WatcherRewardError::InternalError( - "Nft Protocol is not supported yet!".to_string(), - )) + return MmError::err(WatcherRewardError::InternalError(format!( + "{} protocol is not supported by watchers yet!", + self.coin_type + ))) }, } }, @@ -2451,6 +2603,27 @@ impl WatcherOps for EthCoin { } } +// Fallback WatcherOps implementation when ETH watchers are disabled. +// Uses default implementations from the trait which return "not implemented" errors. +#[cfg(not(feature = "enable-eth-watchers"))] +#[async_trait] +impl WatcherOps for EthCoin {} + +/// Broadcasts a hex-encoded TRON transaction via the TRON API client with node rotation. +fn tron_broadcast_hex_fut(coin: EthCoin, tx_hex: String) -> Box + Send> { + let fut = async move { + let tron = coin + .0 + .tron_rpc() + .ok_or_else(|| ERRL!("TRON RPC client is not initialized"))?; + tron.broadcast_hex(&tx_hex) + .await + .map(|resp| resp.txid) + .map_err(|e| ERRL!("TRON broadcast_hex failed: {}", e.into_inner())) + }; + Box::new(fut.boxed().compat()) +} + #[async_trait] #[cfg_attr(test, mockable)] impl MarketCoinOps for EthCoin { @@ -2460,7 +2633,7 @@ impl MarketCoinOps for EthCoin { fn my_address(&self) -> MmResult { match self.derivation_method() { - DerivationMethod::SingleAddress(my_address) => Ok(my_address.display_address()), + DerivationMethod::SingleAddress(ref my_address) => Ok(my_address.display_address()), DerivationMethod::HDWallet(_) => MmError::err(MyAddressError::UnexpectedDerivationMethod( "'my_address' is deprecated for HD wallets".to_string(), )), @@ -2469,7 +2642,7 @@ impl MarketCoinOps for EthCoin { fn address_from_pubkey(&self, pubkey: &H264Json) -> MmResult { let addr = addr_from_raw_pubkey(&pubkey.0).map_err(AddressFromPubkeyError::InternalError)?; - Ok(addr.display_address()) + Ok(self.format_raw_address(addr)) } async fn get_public_key(&self) -> Result> { @@ -2521,6 +2694,13 @@ impl MarketCoinOps for EthCoin { } fn sign_message(&self, message: &str, address: Option) -> SignatureResult { + // TRON message signing uses a different format and is not yet implemented + if matches!(self.chain_spec, ChainSpec::Tron { .. }) { + return MmError::err(SignatureError::InternalError( + "Message signing is not yet implemented for TRON".to_string(), + )); + } + let message_hash = self.sign_message_hash(message).ok_or(SignatureError::PrefixNotFound)?; let secret = if let Some(address) = address { @@ -2548,15 +2728,27 @@ impl MarketCoinOps for EthCoin { Ok(format!("0x{signature}")) } + // TODO: TRON message verification uses a different signing format (TIP-191 or similar). + // When implementing TRON message verification: + // 1. Add a TRON-specific verify function (e.g., `verify_tron_message`) + // 2. Create a wrapper that dispatches based on chain_spec + // 3. Update this function to use the wrapper with ChainTaggedAddress fn verify_message(&self, signature: &str, message: &str, address: &str) -> VerificationResult { + // TRON message verification is not yet implemented + if matches!(self.chain_spec, ChainSpec::Tron { .. }) { + return MmError::err(VerificationError::InternalError( + "Message verification is not yet implemented for TRON".to_string(), + )); + } + let message_hash = self .sign_message_hash(message) .ok_or(VerificationError::PrefixNotFound)?; - let address = self + let tagged_address = self .address_from_str(address) .map_err(VerificationError::AddressDecodingError)?; let signature = Signature::from_str(signature.strip_prefix("0x").unwrap_or(signature))?; - let is_verified = verify_address(&address, &signature, &H256::from(message_hash))?; + let is_verified = verify_address(&tagged_address.inner(), &signature, &H256::from(message_hash))?; Ok(is_verified) } @@ -2573,10 +2765,21 @@ impl MarketCoinOps for EthCoin { } fn platform_coin_balance(&self) -> BalanceFut { - Box::new( - self.eth_balance() - .and_then(move |result| u256_to_big_decimal(result, ETH_DECIMALS).map_mm_err()), - ) + match &self.coin_type { + // Platform coin (ETH / TRX): own balance is the platform balance. + EthCoinType::Eth => Box::new(self.my_balance().map(|b| b.spendable)), + // Token: fetch the native platform balance (ETH or TRX). + EthCoinType::Erc20 { .. } | EthCoinType::Nft { .. } => { + let decimals = self.native_decimals(); + let coin = self.clone(); + let fut = async move { + let my_address = coin.derivation_method.single_addr_or_err().await.map_mm_err()?.inner(); + let balance = coin.native_balance(my_address).await?; + u256_to_big_decimal(balance, decimals).map_mm_err() + }; + Box::new(fut.boxed().compat()) + }, + } } fn platform_ticker(&self) -> &str { @@ -2587,35 +2790,46 @@ impl MarketCoinOps for EthCoin { } fn send_raw_tx(&self, mut tx: &str) -> Box + Send> { - if tx.starts_with("0x") { - tx = &tx[2..]; + match ChainFamily::from(&self.chain_spec) { + ChainFamily::Evm => { + if tx.starts_with("0x") { + tx = &tx[2..]; + } + let bytes = try_fus!(hex::decode(tx)); + let coin = self.clone(); + let fut = async move { + coin.send_raw_transaction(bytes.into()) + .await + .map(|res| format!("{res:02x}")) // TODO: add 0x hash (use unified hash format for eth wherever it is returned) + .map_err(|e| ERRL!("{}", e)) + }; + Box::new(fut.boxed().compat()) + }, + ChainFamily::Tron => { + let tx_hex = try_fus!(normalize_tron_raw_tx_hex(tx)); + tron_broadcast_hex_fut(self.clone(), tx_hex) + }, } - let bytes = try_fus!(hex::decode(tx)); - - let coin = self.clone(); - - let fut = async move { - coin.send_raw_transaction(bytes.into()) - .await - .map(|res| format!("{res:02x}")) // TODO: add 0x hash (use unified hash format for eth wherever it is returned) - .map_err(|e| ERRL!("{}", e)) - }; - - Box::new(fut.boxed().compat()) } fn send_raw_tx_bytes(&self, tx: &[u8]) -> Box + Send> { - let coin = self.clone(); - - let tx = tx.to_owned(); - let fut = async move { - coin.send_raw_transaction(tx.into()) - .await - .map(|res| format!("{res:02x}")) - .map_err(|e| ERRL!("{}", e)) - }; - - Box::new(fut.boxed().compat()) + match ChainFamily::from(&self.chain_spec) { + ChainFamily::Evm => { + let coin = self.clone(); + let tx = tx.to_owned(); + let fut = async move { + coin.send_raw_transaction(tx.into()) + .await + .map(|res| format!("{res:02x}")) + .map_err(|e| ERRL!("{}", e)) + }; + Box::new(fut.boxed().compat()) + }, + ChainFamily::Tron => { + try_fus!(validate_tron_raw_tx_len(tx.len())); + tron_broadcast_hex_fut(self.clone(), hex::encode(tx)) + }, + } } async fn sign_raw_tx(&self, args: &SignRawTransactionRequest) -> RawTransactionResult { @@ -2735,7 +2949,8 @@ impl MarketCoinOps for EthCoin { EthCoinType::Erc20 { .. } => get_function_name("erc20Payment", args.watcher_reward), EthCoinType::Nft { .. } => { return Err(TransactionErr::ProtocolNotSupported(ERRL!( - "Nft Protocol is not supported yet!" + "{} protocol is not supported by legacy swap", + self.coin_type ))) }, }; @@ -2773,6 +2988,13 @@ impl MarketCoinOps for EthCoin { let coin = self.clone(); let fut = async move { + // Use the accessor to avoid spreading chain-specific branching. + if let Some(tron_client) = coin.0.tron_rpc() { + return tron_client + .current_block() + .await + .map_err(|e| ERRL!("TRON current_block failed: {}", e.into_inner())); + } coin.block_number() .await .map(|res| res.as_u64()) @@ -2852,15 +3074,11 @@ async fn sign_transaction_with_keypair( let tx_builder = tx_builder_with_pay_for_gas_option(coin, tx_builder, pay_for_gas_option) .map_err(|e| TransactionErr::Plain(e.get_inner().to_string()))?; let tx = tx_builder.build()?; - let chain_id = match coin.chain_spec { - ChainSpec::Evm { chain_id } => chain_id, - // Todo: Add Tron signing logic - ChainSpec::Tron { .. } => { - return Err(TransactionErr::Plain( - "Tron is not supported for sign_transaction_with_keypair yet".into(), - )) - }, - }; + // Todo: Add Tron signing logic + let chain_id = coin + .chain_spec + .chain_id() + .ok_or_else(|| TransactionErr::Plain("Tron is not supported for sign_transaction_with_keypair yet".into()))?; let signed_tx = tx.sign(key_pair.secret(), Some(chain_id))?; Ok((signed_tx, web3_instances_with_latest_nonce)) @@ -2916,7 +3134,7 @@ async fn sign_and_send_transaction_with_metamask( coin.get_swap_pay_for_gas_option(try_tx_s!(coin.get_swap_gas_fee_policy().await)) .await ); - let my_address = try_tx_s!(coin.derivation_method.single_addr_or_err().await); + let my_address = try_tx_s!(coin.derivation_method.single_addr_or_err().await).inner(); let gas_price = pay_for_gas_option.get_gas_price(); let (max_fee_per_gas, max_priority_fee_per_gas) = pay_for_gas_option.get_fee_per_gas(); let tx_to_send = TransactionRequest { @@ -2975,7 +3193,8 @@ async fn sign_raw_eth_tx(coin: &EthCoin, args: &SignEthTransactionParams) -> Raw .derivation_method .single_addr_or_err() .await - .mm_err(|e| RawTransactionError::InternalError(e.to_string()))?; + .mm_err(|e| RawTransactionError::InternalError(e.to_string()))? + .inner(); let address_lock = coin.get_address_lock(my_address).await; let _nonce_lock = address_lock.lock().await; let pay_for_gas_option = coin @@ -3014,7 +3233,8 @@ async fn sign_raw_eth_tx(coin: &EthCoin, args: &SignEthTransactionParams) -> Raw .derivation_method .single_addr_or_err() .await - .mm_err(|e| RawTransactionError::InternalError(e.to_string()))?; + .mm_err(|e| RawTransactionError::InternalError(e.to_string()))? + .inner(); let address_lock = coin.get_address_lock(my_address).await; let _nonce_lock = address_lock.lock().await; let pay_for_gas_option = coin @@ -3122,6 +3342,33 @@ impl RpcCommonOps for EthCoin { } impl EthCoin { + #[inline] + pub fn tag_address(&self, raw: Address) -> ChainTaggedAddress { + let family = ChainFamily::from(&self.0.chain_spec); + ChainTaggedAddress::new(raw, family) + } + + /// Formats a raw `ethereum_types::Address` for user-facing output based on this coin's chain. + /// + /// Use this when you have a raw address from external sources (RPC responses, logs, + /// contract calls, `ownerOf` queries, etc.) that needs chain-aware formatting. + /// + /// - **EVM chains**: Returns EIP-55 mixed-case checksum format (`0xAbCd...`) + /// - **TRON**: Returns Base58Check format (`T...`) + /// + /// # When to use this vs `ChainTaggedAddress::display_address()` + /// + /// - Use `format_raw_address` for external/RPC-sourced addresses (no chain context attached) + /// - Use `ChainTaggedAddress::display_address()` for wallet-owned addresses from HD derivation + /// + /// See `eth_hd_wallet.rs` for the complete address formatting policy. + /// Formats a raw address using the coin's chain context. + /// + /// Delegates to the canonical `ChainFamily::format` method. + pub fn format_raw_address(&self, raw: Address) -> String { + ChainFamily::from(&self.0.chain_spec).format(raw) + } + pub(crate) async fn web3(&self) -> Result, Web3RpcError> { self.get_live_client().await.map(|t| t.0) } @@ -3210,7 +3457,7 @@ impl EthCoin { let delta = U64::from(1000); let my_address = match self.derivation_method.single_addr_or_err().await { - Ok(addr) => addr, + Ok(addr) => addr.inner(), Err(e) => { ctx.log.log( "", @@ -3466,7 +3713,7 @@ impl EthCoin { ctx.log.log( "", &[&"tx_history", &self.ticker], - &ERRL!("Error on getting fee coin: Nft Protocol is not supported yet!"), + &ERRL!("Error on getting fee coin: {} is not supported yet!", self.coin_type), ); continue; }, @@ -3525,8 +3772,8 @@ impl EthCoin { spent_by_me, received_by_me, total_amount, - to: vec![call_data.to.display_address()], - from: vec![call_data.from.display_address()], + to: vec![self.format_raw_address(call_data.to)], + from: vec![self.format_raw_address(call_data.from)], coin: self.ticker.clone(), fee_details: fee_details.map(|d| d.into()), block_height: trace.block_number, @@ -3577,7 +3824,7 @@ impl EthCoin { let delta = U64::from(10000); let my_address = match self.derivation_method.single_addr_or_err().await { - Ok(addr) => addr, + Ok(addr) => addr.inner(), Err(e) => { ctx.log.log( "", @@ -3855,7 +4102,7 @@ impl EthCoin { ctx.log.log( "", &[&"tx_history", &self.ticker], - &ERRL!("Error on getting fee coin: Nft Protocol is not supported yet!"), + &ERRL!("Error on getting fee coin: {} is not supported yet!", self.coin_type), ); continue; }, @@ -3898,8 +4145,8 @@ impl EthCoin { spent_by_me, received_by_me, total_amount, - to: vec![to_addr.display_address()], - from: vec![from_addr.display_address()], + to: vec![self.format_raw_address(to_addr)], + from: vec![self.format_raw_address(from_addr)], coin: self.ticker.clone(), fee_details: fee_details.map(|d| d.into()), block_height: block_number.as_u64(), @@ -4000,7 +4247,8 @@ impl EthCoin { .derivation_method .single_addr_or_err() .await - .map_err(|e| TransactionErr::Plain(ERRL!("{}", e)))?; + .map_err(|e| TransactionErr::Plain(ERRL!("{}", e)))? + .inner(); sign_and_send_transaction_with_keypair(&coin, key_pair, address, value, action, data, final_gas) .await @@ -4015,7 +4263,8 @@ impl EthCoin { .derivation_method .single_addr_or_err() .await - .map_err(|e| TransactionErr::Plain(ERRL!("{}", e)))?; + .map_err(|e| TransactionErr::Plain(ERRL!("{}", e)))? + .inner(); send_transaction_with_walletconnect(coin, &wc, address, value, action, &data, final_gas).await }, @@ -4052,7 +4301,8 @@ impl EthCoin { ) }, EthCoinType::Nft { .. } => Box::new(futures01::future::err(TransactionErr::ProtocolNotSupported(ERRL!( - "Nft Protocol is not supported yet!" + "{} Protocol is not supported", + self.coin_type )))), } } @@ -4079,7 +4329,10 @@ impl EthCoin { let mut value = trade_amount; let data = match &args.watcher_reward { Some(reward) => { - let reward_amount = try_tx_fus!(u256_from_big_decimal(&reward.amount, self.decimals)); + // Apply overpay factor to reward to handle gas price volatility between payment time and validation time until better things are in place. + let overpay_factor = BigDecimal::from_f64(REWARD_OVERPAY_FACTOR).unwrap_or(BigDecimal::from(1)); + let reward_with_overpay = &reward.amount * overpay_factor; + let reward_amount = try_tx_fus!(u256_from_big_decimal(&reward_with_overpay, self.decimals)); if !matches!(reward.reward_target, RewardTarget::None) || reward.send_contract_reward_on_spend { value += reward_amount; } @@ -4122,16 +4375,19 @@ impl EthCoin { let data = match args.watcher_reward { Some(reward) => { + // Apply overpay factor to reward to handle gas price volatility between payment time and validation time + let overpay_factor = BigDecimal::from_f64(REWARD_OVERPAY_FACTOR).unwrap_or(BigDecimal::from(1)); + let reward_with_overpay = &reward.amount * overpay_factor; let reward_amount = match reward.reward_target { RewardTarget::Contract | RewardTarget::PaymentSender => { let eth_reward_amount = - try_tx_fus!(u256_from_big_decimal(&reward.amount, ETH_DECIMALS)); + try_tx_fus!(u256_from_big_decimal(&reward_with_overpay, ETH_DECIMALS)); value += eth_reward_amount; eth_reward_amount }, RewardTarget::PaymentSpender => { let token_reward_amount = - try_tx_fus!(u256_from_big_decimal(&reward.amount, self.decimals)); + try_tx_fus!(u256_from_big_decimal(&reward_with_overpay, self.decimals)); amount += token_reward_amount; token_reward_amount }, @@ -4139,7 +4395,7 @@ impl EthCoin { // TODO tests passed without this change, need to research on how it worked if reward.send_contract_reward_on_spend { let eth_reward_amount = - try_tx_fus!(u256_from_big_decimal(&reward.amount, ETH_DECIMALS)); + try_tx_fus!(u256_from_big_decimal(&reward_with_overpay, ETH_DECIMALS)); value += eth_reward_amount; eth_reward_amount } else { @@ -4216,11 +4472,13 @@ impl EthCoin { })) }, EthCoinType::Nft { .. } => Box::new(futures01::future::err(TransactionErr::ProtocolNotSupported(ERRL!( - "Nft Protocol is not supported yet!" + "{} protocol is not supported", + self.coin_type )))), } } + #[cfg(feature = "enable-eth-watchers")] fn watcher_spends_hash_time_locked_payment(&self, input: SendMakerPaymentSpendPreimageInput) -> EthTxFut { let tx: UnverifiedTransactionWrapper = try_tx_fus!(rlp::decode(input.preimage)); let payment = try_tx_fus!(SignedEthTx::new(tx)); @@ -4335,11 +4593,13 @@ impl EthCoin { ) }, EthCoinType::Nft { .. } => Box::new(futures01::future::err(TransactionErr::ProtocolNotSupported(ERRL!( - "Nft Protocol is not supported yet!" + "{} protocol is not supported by watchers", + self.coin_type )))), } } + #[cfg(feature = "enable-eth-watchers")] fn watcher_refunds_hash_time_locked_payment(&self, args: RefundPaymentArgs) -> EthTxFut { let tx: UnverifiedTransactionWrapper = try_tx_fus!(rlp::decode(args.payment_tx)); let payment = try_tx_fus!(SignedEthTx::new(tx)); @@ -4458,7 +4718,8 @@ impl EthCoin { ) }, EthCoinType::Nft { .. } => Box::new(futures01::future::err(TransactionErr::ProtocolNotSupported(ERRL!( - "Nft Protocol is not supported yet!" + "{} protocol is not supported by watchers", + self.coin_type )))), } } @@ -4469,7 +4730,7 @@ impl EthCoin { ) -> Result { let tx: UnverifiedTransactionWrapper = try_tx_s!(rlp::decode(args.other_payment_tx)); let payment = try_tx_s!(SignedEthTx::new(tx)); - let my_address = try_tx_s!(self.derivation_method.single_addr_or_err().await); + let my_address = try_tx_s!(self.derivation_method.single_addr_or_err().await).inner(); let swap_contract_address = try_tx_s!(args.swap_contract_address.try_to_address()); let function_name = get_function_name("receiverSpend", args.watcher_reward); @@ -4581,7 +4842,8 @@ impl EthCoin { .await }, EthCoinType::Nft { .. } => Err(TransactionErr::ProtocolNotSupported(ERRL!( - "Nft Protocol is not supported!" + "{} protocols is not supported by legacy swap", + self.coin_type ))), } } @@ -4592,7 +4854,7 @@ impl EthCoin { ) -> Result { let tx: UnverifiedTransactionWrapper = try_tx_s!(rlp::decode(args.payment_tx)); let payment = try_tx_s!(SignedEthTx::new(tx)); - let my_address = try_tx_s!(self.derivation_method.single_addr_or_err().await); + let my_address = try_tx_s!(self.derivation_method.single_addr_or_err().await).inner(); let swap_contract_address = try_tx_s!(args.swap_contract_address.try_to_address()); let function_name = get_function_name("senderRefund", args.watcher_reward); @@ -4704,35 +4966,34 @@ impl EthCoin { .await }, EthCoinType::Nft { .. } => Err(TransactionErr::ProtocolNotSupported(ERRL!( - "Nft Protocol is not supported yet!" + "{} protocol is not supported", + self.coin_type ))), } } - fn address_balance(&self, address: Address) -> BalanceFut { + fn address_balance(&self, address: ChainTaggedAddress) -> BalanceFut { let coin = self.clone(); let fut = async move { - match coin.coin_type { - EthCoinType::Eth => Ok(coin.balance(address, Some(BlockNumber::Latest)).await?), - EthCoinType::Erc20 { ref token_addr, .. } => { - let function = ERC20_CONTRACT.function("balanceOf")?; - let data = function.encode_input(&[Token::Address(address)])?; + let coin_family = ChainFamily::from(&coin.0.chain_spec); + + // Strict mismatch check - address must be tagged for the same chain + if address.family() != coin_family { + return MmError::err(BalanceError::Internal(format!( + "Address family mismatch: address is {:?} but coin is {:?}", + address.family(), + coin_family + ))); + } - let res = coin - .call_request(address, *token_addr, None, Some(data.into()), BlockNumber::Latest) - .await?; - let decoded = function.decode_output(&res.0)?; - match decoded[0] { - Token::Uint(number) => Ok(number), - _ => { - let error = format!("Expected U256 as balanceOf result but got {decoded:?}"); - MmError::err(BalanceError::InvalidResponse(error)) - }, - } - }, - EthCoinType::Nft { .. } => { - MmError::err(BalanceError::Internal("Nft Protocol is not supported yet!".to_string())) - }, + let raw = address.inner(); + match &coin.coin_type { + EthCoinType::Eth => coin.native_balance(raw).await, + EthCoinType::Erc20 { token_addr, .. } => coin.get_token_balance_for_address(raw, *token_addr).await, + EthCoinType::Nft { .. } => MmError::err(BalanceError::Internal(format!( + "{} is not supported yet!", + coin.coin_type + ))), } }; Box::new(fut.boxed().compat()) @@ -4772,34 +5033,86 @@ impl EthCoin { } pub async fn get_tokens_balance_list(&self) -> Result> { - let my_address = self.derivation_method.single_addr_or_err().await.map_mm_err()?; + let my_address = self.derivation_method.single_addr_or_err().await.map_mm_err()?.inner(); self.get_tokens_balance_list_for_address(my_address).await } + /// Chain-dispatched token decimals query. + /// + /// - EVM: ERC20 `decimals()` via `eth_call` + /// - TRON: TRC20 `decimals()` via `TronApiClient::trc20_decimals` + pub(crate) async fn token_decimals(&self, token_contract: Address) -> MmResult { + match ChainFamily::from(&self.chain_spec) { + ChainFamily::Evm => { + let web3 = self.web3().await?; + erc20::get_token_decimals(&web3, token_contract).await + }, + ChainFamily::Tron => { + let tron = self.tron_rpc().ok_or_else(|| { + MmError::new(Web3RpcError::Transport("TRON RPC client is not initialized".into())) + })?; + + // triggerconstantcontract requires an owner_address that becomes msg.sender + // in the TVM. For decimals() the caller is irrelevant (pure function), but + // we use the wallet address when available for consistency with other constant + // calls that may check msg.sender (e.g. access control). + // + // single_addr() returns None in HD mode when the enabled address hasn't been + // derived yet (e.g. account not populated, or token init runs before HD scan + // completes). unwrap_or falls back to the contract address which is guaranteed + // to exist on-chain. + let caller = self + .derivation_method + .single_addr() + .await + .map(|a| a.inner()) + .unwrap_or(token_contract); + + let caller_tron = TronAddress::from(caller); + let contract_tron = TronAddress::from(token_contract); + + tron.trc20_decimals(&contract_tron, &caller_tron).await + }, + } + } + + /// Chain-dispatched token balance query. + /// + /// - EVM: ERC20 `balanceOf(address)` via `eth_call` + /// - TRON: TRC20 `balanceOf(address)` via `TronApiClient::trc20_balance_of` async fn get_token_balance_for_address( &self, address: Address, token_address: Address, ) -> Result> { - let function = ERC20_CONTRACT.function("balanceOf")?; - let data = function.encode_input(&[Token::Address(address)])?; - let res = self - .call_request(address, token_address, None, Some(data.into()), BlockNumber::Latest) - .await?; - let decoded = function.decode_output(&res.0)?; + match ChainFamily::from(&self.chain_spec) { + ChainFamily::Evm => { + let function = ERC20_CONTRACT.function("balanceOf")?; + let data = function.encode_input(&[Token::Address(address)])?; - match decoded[0] { - Token::Uint(number) => Ok(number), - _ => { - let error = format!("Expected U256 as balanceOf result but got {decoded:?}"); - MmError::err(BalanceError::InvalidResponse(error)) + let res = self + .call_request(address, token_address, None, Some(data.into()), BlockNumber::Latest) + .await?; + + let decoded = function.decode_output(&res.0)?; + match decoded.first() { + Some(Token::Uint(number)) => Ok(*number), + other => MmError::err(BalanceError::InvalidResponse(format!( + "Expected U256 as balanceOf result but got {other:?}" + ))), + } }, - } - } + ChainFamily::Tron => { + let tron = self + .tron_rpc() + .ok_or_else(|| MmError::new(BalanceError::Internal("TRON RPC client is not initialized".into())))?; + + let owner_tron = TronAddress::from(address); + let contract_tron = TronAddress::from(token_address); - async fn get_token_balance(&self, token_address: Address) -> Result> { - let my_address = self.derivation_method.single_addr_or_err().await.map_mm_err()?; - self.get_token_balance_for_address(my_address, token_address).await + tron.trc20_balance_of(&contract_tron, &owner_tron).await.map_mm_err() + }, + } } async fn erc1155_balance(&self, token_addr: Address, token_id: &str) -> MmResult { @@ -4809,7 +5122,7 @@ impl EthCoin { let token_id_u256 = U256::from_dec_str(token_id) .map_to_mm(|e| NumConversError::new(format!("{e:?}"))) .map_mm_err()?; - let my_address = self.derivation_method.single_addr_or_err().await.map_mm_err()?; + let my_address = self.derivation_method.single_addr_or_err().await.map_mm_err()?.inner(); let data = function.encode_input(&[Token::Address(my_address), Token::Uint(token_id_u256)])?; let result = self .call_request(my_address, token_addr, None, Some(data.into()), BlockNumber::Latest) @@ -4824,9 +5137,10 @@ impl EthCoin { } }, EthCoinType::Erc20 { .. } => { - return MmError::err(BalanceError::Internal( - "Erc20 coin type doesnt support Erc1155 standard".to_owned(), - )) + return MmError::err(BalanceError::Internal(format!( + "{:?} protocol doesnt support Erc1155 standard", + self.coin_type + ))) }, }; // The "balanceOf" function in ERC1155 standard returns the exact count of tokens held by address without any decimals or scaling factors @@ -4842,7 +5156,7 @@ impl EthCoin { .map_to_mm(|e| NumConversError::new(format!("{e:?}"))) .map_mm_err()?; let data = function.encode_input(&[Token::Uint(token_id_u256)])?; - let my_address = self.derivation_method.single_addr_or_err().await.map_mm_err()?; + let my_address = self.derivation_method.single_addr_or_err().await.map_mm_err()?.inner(); let result = self .call_request(my_address, token_addr, None, Some(data.into()), BlockNumber::Latest) .await?; @@ -4856,9 +5170,10 @@ impl EthCoin { } }, EthCoinType::Erc20 { .. } => { - return MmError::err(GetNftInfoError::Internal( - "Erc20 coin type doesnt support Erc721 standard".to_owned(), - )) + return MmError::err(GetNftInfoError::Internal(format!( + "{:?} protocol doesnt support Erc721 standard", + self.coin_type + ))) }, }; Ok(owner_address) @@ -4890,7 +5205,7 @@ impl EthCoin { value: U256, ) -> Web3RpcResult { let coin = self.clone(); - let my_address = coin.derivation_method.single_addr_or_err().await.map_mm_err()?; + let my_address = coin.derivation_method.single_addr_or_err().await.map_mm_err()?.inner(); let fee_policy_for_estimate = get_swap_fee_policy_for_estimate(self.get_swap_gas_fee_policy().await.map_mm_err()?); let pay_for_gas_option = coin @@ -4933,15 +5248,31 @@ impl EthCoin { } } - fn eth_balance(&self) -> BalanceFut { - let coin = self.clone(); - let fut = async move { - let my_address = coin.derivation_method.single_addr_or_err().await.map_mm_err()?; - coin.balance(my_address, Some(BlockNumber::Latest)) + /// Returns the native platform balance (ETH or TRX) for the given raw address. + async fn native_balance(&self, address: Address) -> MmResult { + match ChainFamily::from(&self.0.chain_spec) { + ChainFamily::Evm => self + .balance(address, Some(BlockNumber::Latest)) .await - .map_to_mm(BalanceError::from) - }; - Box::new(fut.boxed().compat()) + .map_to_mm(BalanceError::from), + ChainFamily::Tron => { + let tron = self + .0 + .tron_rpc() + .or_mm_err(|| BalanceError::Internal("TRON chain but no TRON rpc_client".to_string()))?; + tron.balance_native(tron::TronAddress::from(address)) + .await + .map_err(|e| BalanceError::Transport(e.into_inner().to_string()).into()) + }, + } + } + + /// Returns the decimals of the native platform coin (ETH=18, TRX=6). + fn native_decimals(&self) -> u8 { + match &self.0.chain_spec { + ChainSpec::Evm { .. } => ETH_DECIMALS, + ChainSpec::Tron { .. } => tron::TRX_DECIMALS, + } } pub(crate) async fn call_request( @@ -4974,7 +5305,7 @@ impl EthCoin { )), EthCoinType::Erc20 { ref token_addr, .. } => { let function = ERC20_CONTRACT.function("allowance")?; - let my_address = coin.derivation_method.single_addr_or_err().await.map_mm_err()?; + let my_address = coin.derivation_method.single_addr_or_err().await.map_mm_err()?.inner(); let data = function.encode_input(&[Token::Address(my_address), Token::Address(spender)])?; let res = coin @@ -4990,7 +5321,10 @@ impl EthCoin { }, } }, - EthCoinType::Nft { .. } => MmError::err(Web3RpcError::NftProtocolNotSupported), + EthCoinType::Nft { .. } => MmError::err(Web3RpcError::ProtocolNotSupported(format!( + "{} protocol is not supported by allowance", + &coin.coin_type + ))), } }; Box::new(fut.boxed().compat()) @@ -5038,7 +5372,8 @@ impl EthCoin { EthCoinType::Erc20 { token_addr, .. } => token_addr, EthCoinType::Nft { .. } => { return Err(TransactionErr::ProtocolNotSupported(ERRL!( - "Nft Protocol is not supported by 'approve'!" + "{} is not supported by 'approve'!", + coin.coin_type ))) }, }; @@ -5147,7 +5482,7 @@ impl EthCoin { ))); } - let my_address = selfi.derivation_method.single_addr_or_err().await.map_mm_err()?; + let my_address = selfi.derivation_method.single_addr_or_err().await.map_mm_err()?.inner(); match &selfi.coin_type { EthCoinType::Eth => { let mut expected_value = trade_amount; @@ -5374,9 +5709,10 @@ impl EthCoin { } }, EthCoinType::Nft { .. } => { - return MmError::err(ValidatePaymentError::ProtocolNotSupported( - "Nft protocol is not supported by legacy swap".to_string(), - )) + return MmError::err(ValidatePaymentError::ProtocolNotSupported(format!( + "{} is not supported by legacy swap", + selfi.coin_type + ))) }, } @@ -5400,7 +5736,8 @@ impl EthCoin { .derivation_method .single_addr_or_err() .await - .map_err(|e| ERRL!("{}", e))?; + .map_err(|e| ERRL!("{}", e))? + .inner(); coin.call_request( my_address, swap_contract_address, @@ -5429,21 +5766,50 @@ impl EthCoin { &self, tx: &[u8], swap_contract_address: Address, - _secret_hash: &[u8], search_from_block: u64, - watcher_reward: bool, ) -> Result, String> { let unverified: UnverifiedTransactionWrapper = try_s!(rlp::decode(tx)); let tx = try_s!(SignedEthTx::new(unverified)); + let tx_data = tx.unsigned().data(); + if tx_data.len() < 4 { + return ERR!("Transaction data too short to contain function selector"); + } + let actual_selector = &tx_data[0..4]; + + // Auto-detect which payment function variant was used by matching the function selector. + // The id (first argument) is at the same position in all variants. + // Note: Reward functions may not exist until watcher-compatible contracts are deployed. + let (payment_func_name, payment_func_reward_name) = match self.coin_type { + EthCoinType::Eth => ("ethPayment", "ethPaymentReward"), + EthCoinType::Erc20 { .. } => ("erc20Payment", "erc20PaymentReward"), + EthCoinType::Nft { .. } => return ERR!("{} is not supported yet!", self.coin_type), + }; - let func_name = match self.coin_type { - EthCoinType::Eth => get_function_name("ethPayment", watcher_reward), - EthCoinType::Erc20 { .. } => get_function_name("erc20Payment", watcher_reward), - EthCoinType::Nft { .. } => return ERR!("Nft Protocol is not supported yet!"), + let payment_func = try_s!(SWAP_CONTRACT.function(payment_func_name)); + let payment_func_reward = SWAP_CONTRACT.function(payment_func_reward_name).ok(); + + let func_to_use = if actual_selector == payment_func.short_signature() { + payment_func + } else if let Some(reward_func) = payment_func_reward.as_ref() { + if actual_selector == reward_func.short_signature() { + reward_func + } else { + return ERR!( + "Transaction is not a payment call. Expected selector {:?} or {:?}, found {:?}", + payment_func.short_signature(), + reward_func.short_signature(), + actual_selector + ); + } + } else { + return ERR!( + "Transaction is not a payment call. Expected selector {:?}, found {:?}", + payment_func.short_signature(), + actual_selector + ); }; - let payment_func = try_s!(SWAP_CONTRACT.function(&func_name)); - let decoded = try_s!(decode_contract_call(payment_func, tx.unsigned().data())); + let decoded = try_s!(decode_contract_call(func_to_use, tx_data)); let id = match decoded.first() { Some(Token::FixedBytes(bytes)) => bytes.clone(), invalid_token => return ERR!("Expected Token::FixedBytes, got {:?}", invalid_token), @@ -5887,6 +6253,11 @@ impl EthCoin { Box::new(Box::pin(fut).compat()) } + /// Helper method to check if this is a TRON blockchain + pub fn is_tron(&self) -> bool { + matches!(self.0.chain_spec, ChainSpec::Tron { .. }) + } + pub async fn platform_coin(&self) -> CoinFindResult { match &self.coin_type { EthCoinType::Eth => Ok(self.clone()), @@ -6052,7 +6423,7 @@ impl MmCoin for EthCoin { let fee_coin = match &coin.coin_type { EthCoinType::Eth => &coin.ticker, EthCoinType::Erc20 { platform, .. } => platform, - EthCoinType::Nft { .. } => return ERR!("Nft Protocol is not supported yet!"), + EthCoinType::Nft { .. } => return ERR!("{} is not supported yet!", coin.coin_type), }; Ok(TradeFee { coin: fee_coin.into(), @@ -6097,7 +6468,7 @@ impl MmCoin for EthCoin { // estimate gas for the `approve` contract call // Pass a dummy spender. Let's use `my_address`. - let spender = self.derivation_method.single_addr_or_err().await.map_mm_err()?; + let spender = self.derivation_method.single_addr_or_err().await.map_mm_err()?.inner(); let approve_function = ERC20_CONTRACT.function("approve")?; let approve_data = approve_function.encode_input(&[Token::Address(spender), Token::Uint(value)])?; let approve_gas_limit = self @@ -6114,7 +6485,12 @@ impl MmCoin for EthCoin { } gas }, - EthCoinType::Nft { .. } => return MmError::err(TradePreimageError::NftProtocolNotSupported), + EthCoinType::Nft { .. } => { + return MmError::err(TradePreimageError::ProtocolNotSupported(format!( + "{} protocol is not supported", + self.coin_type + ))) + }, }; let total_fee = calc_total_fee(gas_limit, &pay_for_gas_option).map_mm_err()?; @@ -6122,7 +6498,12 @@ impl MmCoin for EthCoin { let fee_coin = match &self.coin_type { EthCoinType::Eth => &self.ticker, EthCoinType::Erc20 { platform, .. } => platform, - EthCoinType::Nft { .. } => return MmError::err(TradePreimageError::NftProtocolNotSupported), + EthCoinType::Nft { .. } => { + return MmError::err(TradePreimageError::ProtocolNotSupported(format!( + "{} protocol is not supported", + self.coin_type + ))) + }, }; Ok(TradeFee { coin: fee_coin.into(), @@ -6149,7 +6530,12 @@ impl MmCoin for EthCoin { calc_total_fee(U256::from(coin.gas_limit.erc20_receiver_spend), &pay_for_gas_option) .map_mm_err()?, ), - EthCoinType::Nft { .. } => return MmError::err(TradePreimageError::NftProtocolNotSupported), + EthCoinType::Nft { .. } => { + return MmError::err(TradePreimageError::ProtocolNotSupported(format!( + "{} protocol is not supported by get_receiver_trade_fee", + coin.coin_type + ))); + }, }; let amount = u256_to_big_decimal(total_fee, ETH_DECIMALS).map_mm_err()?; Ok(TradeFee { @@ -6170,7 +6556,7 @@ impl MmCoin for EthCoin { // pass the dummy params let to_addr = addr_from_raw_pubkey(&DEX_FEE_ADDR_RAW_PUBKEY) .expect("addr_from_raw_pubkey should never fail with DEX_FEE_ADDR_RAW_PUBKEY"); - let my_address = self.derivation_method.single_addr_or_err().await.map_mm_err()?; + let my_address = self.derivation_method.single_addr_or_err().await.map_mm_err()?.inner(); let (eth_value, data, call_addr, fee_coin) = match &self.coin_type { EthCoinType::Eth => (dex_fee_amount, Vec::new(), &to_addr, &self.ticker), EthCoinType::Erc20 { platform, token_addr } => { @@ -6178,7 +6564,12 @@ impl MmCoin for EthCoin { let data = function.encode_input(&[Token::Address(to_addr), Token::Uint(dex_fee_amount)])?; (0.into(), data, token_addr, platform) }, - EthCoinType::Nft { .. } => return MmError::err(TradePreimageError::NftProtocolNotSupported), + EthCoinType::Nft { .. } => { + return MmError::err(TradePreimageError::ProtocolNotSupported(format!( + "{} protocol is not supported", + self.coin_type + ))) + }, }; let fee_policy_for_estimate = get_swap_fee_policy_for_estimate(self.get_swap_gas_fee_policy().await.map_mm_err()?); @@ -6389,9 +6780,10 @@ fn validate_fee_impl(coin: EthCoin, validate_fee_args: EthValidateFeeArgs<'_>) - } }, EthCoinType::Nft { .. } => { - return MmError::err(ValidatePaymentError::ProtocolNotSupported( - "Nft protocol is not supported".to_string(), - )) + return MmError::err(ValidatePaymentError::ProtocolNotSupported(format!( + "{} protocol is not supported", + coin.coin_type + ))) }, } @@ -6631,7 +7023,7 @@ pub async fn eth_coin_from_conf_and_request( let mut rng = small_rng(); urls.as_mut_slice().shuffle(&mut rng); - let swap_contract_address: Address = try_s!(json::from_value(req["swap_contract_address"].clone())); + let swap_contract_address = try_s!(json::from_value(req["swap_contract_address"].clone())); if swap_contract_address == Address::default() { return ERR!("swap_contract_address can't be zero address"); } @@ -6647,8 +7039,29 @@ pub async fn eth_coin_from_conf_and_request( req["path_to_address"].clone() )) .unwrap_or_default(); + + let chain_id: u64 = match &protocol { + CoinProtocol::ETH { chain_id } => *chain_id, + CoinProtocol::ERC20 { platform, .. } | CoinProtocol::NFT { platform } => { + get_chain_id_from_platform(ctx, ticker, platform)? + }, + CoinProtocol::TRX { .. } => { + return ERR!("TRON/TRX requires V2 activation with ChainSpec::Tron. Legacy V1 activation is EVM-only."); + }, + _ => return ERR!("Expect ETH, ERC20 or NFT protocol"), + }; + let (key_pair, derivation_method) = try_s!( - build_address_and_priv_key_policy(ctx, ticker, conf, priv_key_policy, &path_to_address, None, None).await + build_address_and_priv_key_policy_evm_legacy( + ctx, + ticker, + conf, + priv_key_policy, + &path_to_address, + None, + chain_id + ) + .await ); let mut web3_instances = vec![]; @@ -6699,32 +7112,30 @@ pub async fn eth_coin_from_conf_and_request( return ERR!("Failed to get client version for all urls"); } - let (coin_type, decimals, chain_id) = match protocol { - CoinProtocol::ETH { chain_id } => (EthCoinType::Eth, ETH_DECIMALS, chain_id), + let (coin_type, decimals) = match protocol { + CoinProtocol::ETH { .. } => (EthCoinType::Eth, ETH_DECIMALS), CoinProtocol::ERC20 { platform, contract_address, } => { let token_addr = try_s!(valid_addr_from_str(&contract_address)); let decimals = match conf["decimals"].as_u64() { - None | Some(0) => try_s!( - get_token_decimals( - web3_instances - .first() - .expect("web3_instances can't be empty in ETH activation") - .as_ref(), - token_addr - ) - .await - ), + None | Some(0) => try_s!(erc20::get_token_decimals( + web3_instances + .first() + .expect("web3_instances can't be empty in ETH activation") + .as_ref(), + token_addr + ) + .await + .map_err(|e| e.to_string())), Some(d) => d as u8, }; - let chain_id = get_chain_id_from_platform(ctx, ticker, &platform)?; - (EthCoinType::Erc20 { platform, token_addr }, decimals, chain_id) + (EthCoinType::Erc20 { platform, token_addr }, decimals) }, - CoinProtocol::NFT { platform } => { - let chain_id = get_chain_id_from_platform(ctx, ticker, &platform)?; - (EthCoinType::Nft { platform }, ETH_DECIMALS, chain_id) + CoinProtocol::NFT { platform } => (EthCoinType::Nft { platform }, ETH_DECIMALS), + CoinProtocol::TRX { .. } => { + return ERR!("TRON/TRX requires V2 activation with ChainSpec::Tron. Legacy V1 activation is EVM-only."); }, _ => return ERR!("Expect ETH, ERC20 or NFT protocol"), }; @@ -6788,6 +7199,8 @@ pub async fn eth_coin_from_conf_and_request( decimals, ticker: ticker.into(), web3_instances: AsyncMutex::new(web3_instances), + // Chain-specific RPC client (TRON) not supported for v1 activation + rpc_client: None, history_sync_state: Mutex::new(initial_history_state), swap_gas_fee_policy: Mutex::new(swap_gas_fee_policy), max_eth_tx_type, @@ -6929,10 +7342,30 @@ pub async fn get_eth_address( } .into(); - let (_, derivation_method) = - build_address_and_priv_key_policy(ctx, ticker, conf, priv_key_policy, path_to_address, None, None) - .await - .map_mm_err()?; + let protocol: CoinProtocol = json::from_value(conf["protocol"].clone()) + .map_err(|e| MmError::new(GetEthAddressError::Internal(format!("Error parsing protocol: {}", e))))?; + + let chain_id: u64 = match protocol { + CoinProtocol::ETH { chain_id } => chain_id, + other => { + return MmError::err(GetEthAddressError::Internal(format!( + "get_eth_address is for ETH protocol coins only, got protocol: {:?}", + other + ))); + }, + }; + + let (_, derivation_method) = build_address_and_priv_key_policy_evm_legacy( + ctx, + ticker, + conf, + priv_key_policy, + path_to_address, + None, + chain_id, + ) + .await + .map_mm_err()?; let my_address = derivation_method.single_addr_or_err().await.map_mm_err()?; Ok(MyWalletAddress { @@ -6992,8 +7425,8 @@ pub enum EthGasDetailsErr { Internal(String), #[display(fmt = "Transport: {_0}")] Transport(String), - #[display(fmt = "Nft Protocol is not supported yet!")] - NftProtocolNotSupported, + #[display(fmt = "Protocol not supported: {_0}")] + ProtocolNotSupported(String), #[display(fmt = "No such coin {}", coin)] NoSuchCoin { coin: String }, } @@ -7007,13 +7440,18 @@ impl From for EthGasDetailsErr { impl From for EthGasDetailsErr { fn from(e: Web3RpcError) -> Self { match e { - Web3RpcError::Transport(tr) | Web3RpcError::InvalidResponse(tr) => EthGasDetailsErr::Transport(tr), + Web3RpcError::Transport(tr) + | Web3RpcError::Timeout(tr) + | Web3RpcError::BadResponse(tr) + | Web3RpcError::InvalidResponse(tr) => EthGasDetailsErr::Transport(tr), + Web3RpcError::RemoteError { code, message } => { + EthGasDetailsErr::Transport(format_remote_error(code, message)) + }, Web3RpcError::Internal(internal) - | Web3RpcError::Timeout(internal) | Web3RpcError::NumConversError(internal) | Web3RpcError::InvalidGasApiConfig(internal) => EthGasDetailsErr::Internal(internal), + Web3RpcError::ProtocolNotSupported(e) => EthGasDetailsErr::ProtocolNotSupported(e), Web3RpcError::NoSuchCoin { coin } => EthGasDetailsErr::NoSuchCoin { coin }, - Web3RpcError::NftProtocolNotSupported => EthGasDetailsErr::NftProtocolNotSupported, } } } @@ -7270,13 +7708,14 @@ impl ParseCoinAssocTypes for EthCoin { async fn my_addr(&self) -> Self::Address { match self.derivation_method() { - DerivationMethod::SingleAddress(addr) => *addr, + DerivationMethod::SingleAddress(addr) => addr.inner(), // Todo: Expect should not fail but we need to handle it properly DerivationMethod::HDWallet(hd_wallet) => hd_wallet .get_enabled_address() .await .expect("Getting enabled address should not fail!") - .address(), + .address() + .inner(), } } @@ -7532,7 +7971,10 @@ impl Eip1559Ops for EthCoin { #[cfg(any(test, feature = "run-docker-tests"))] async fn get_swap_gas_fee_policy(&self) -> CoinFindResult { - Ok(SwapGasFeePolicy::default()) + // In tests, return the actual stored policy to allow direct field access tests + let platform_coin = self.platform_coin().await?; + let policy = platform_coin.swap_gas_fee_policy.lock().unwrap().clone(); + Ok(policy) } /// Store gas fee policy for swaps in the platform_coin, for any token @@ -7544,7 +7986,9 @@ impl Eip1559Ops for EthCoin { } #[cfg(any(test, feature = "run-docker-tests"))] - async fn set_swap_gas_fee_policy(&self, _swap_txfee_policy: SwapGasFeePolicy) -> CoinFindResult<()> { + async fn set_swap_gas_fee_policy(&self, swap_txfee_policy: SwapGasFeePolicy) -> CoinFindResult<()> { + let platform_coin = self.platform_coin().await?; + *platform_coin.swap_gas_fee_policy.lock().unwrap() = swap_txfee_policy; Ok(()) } } @@ -7721,7 +8165,9 @@ impl CommonSwapOpsV2 for EthCoin { #[cfg(all(feature = "for-tests", not(target_arch = "wasm32")))] impl EthCoin { - pub async fn set_coin_type(&self, new_coin_type: EthCoinType) -> EthCoin { + /// Creates a new EthCoin with a different coin type and decimals. + /// This is useful for tests that need to convert an ETH coin to ERC20. + pub async fn set_coin_type(&self, new_coin_type: EthCoinType, decimals: u8) -> EthCoin { let coin = EthCoinImpl { ticker: self.ticker.clone(), coin_type: new_coin_type, @@ -7734,7 +8180,8 @@ impl EthCoin { fallback_swap_contract: self.fallback_swap_contract, contract_supports_watchers: self.contract_supports_watchers, web3_instances: AsyncMutex::new(self.web3_instances.lock().await.clone()), - decimals: self.decimals, + rpc_client: self.rpc_client.clone(), + decimals, history_sync_state: Mutex::new(self.history_sync_state.lock().unwrap().clone()), required_confirmations: AtomicU64::new( self.required_confirmations.load(std::sync::atomic::Ordering::SeqCst), diff --git a/mm2src/coins/eth/chain_address.rs b/mm2src/coins/eth/chain_address.rs new file mode 100644 index 0000000000..7b7b6d59ea --- /dev/null +++ b/mm2src/coins/eth/chain_address.rs @@ -0,0 +1,208 @@ +//! Chain-aware address types for EVM and TRON. +//! +//! This module provides `ChainTaggedAddress`, a wrapper that carries chain family +//! context alongside the raw 20-byte address, enabling correct user-facing formatting +//! (EVM checksum vs TRON Base58). + +use super::tron::TronAddress; +use super::{valid_addr_from_str, ChainFamily}; +use crate::hd_wallet::{AddrToString, DisplayAddress}; +use ethereum_types::Address as EthAddress; +use std::fmt; +use std::str::FromStr; + +// ═══════════════════════════════════════════════════════════════════════════════════════════════ +// ADDRESS FORMATTING POLICY +// ═══════════════════════════════════════════════════════════════════════════════════════════════ +// +// Single source of truth: `ChainFamily::format(raw)` is the canonical formatter. +// All other formatting methods delegate to it. +// +// β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +// β”‚ Method β”‚ Use Case β”‚ +// β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +// β”‚ ChainFamily::format(raw) β”‚ Canonical formatter. EVMβ†’0x checksum, TRONβ†’T... base58 β”‚ +// β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +// β”‚ ChainTaggedAddress::display_address β”‚ Wallet-owned addresses from HD derivation. β”‚ +// β”‚ β”‚ Delegates to ChainFamily::format. β”‚ +// β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +// β”‚ EthCoin::format_raw_address(raw) β”‚ External/RPC-sourced addresses (logs, receipts, β”‚ +// β”‚ β”‚ ownerOf, contract calls). Delegates to β”‚ +// β”‚ β”‚ ChainFamily::format using coin's chain spec. β”‚ +// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +// +// NOTE: There is no `impl DisplayAddress for ethereum_types::Address`. Raw addresses cannot +// call `.display_address()` β€” you MUST use one of the chain-aware methods above. +// +// ═══════════════════════════════════════════════════════════════════════════════════════════════ + +/// Address tagged with chain family solely to format it correctly for user-facing outputs. +/// The inner bytes remain `ethereum_types::Address` (20 bytes). +/// +/// This follows the UTXO pattern where different address formats have different types +/// (e.g., `Address` vs `CashAddress`). For ETH/TRON, we use the same underlying +/// 20-byte address but display it differently based on the chain family. +/// +/// To get the underlying `Address`, use `.inner()`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ChainTaggedAddress { + inner: EthAddress, + family: ChainFamily, +} + +impl ChainTaggedAddress { + /// Creates a new chain-tagged address with the specified address and chain family. + pub fn new(address: EthAddress, family: ChainFamily) -> Self { + Self { inner: address, family } + } + + /// Creates a chain-tagged address from a TRON address. + /// + /// Extracts the 20-byte EVM address (dropping the 0x41 prefix) and sets family to TRON. + pub fn from_tron(tron_addr: TronAddress) -> Self { + Self { + inner: tron_addr.to_evm_address(), + family: ChainFamily::Tron, + } + } + + /// Returns the underlying raw address bytes. + #[inline] + pub fn inner(&self) -> EthAddress { + self.inner + } + + /// Returns the chain family this address belongs to. + #[inline] + pub fn family(&self) -> ChainFamily { + self.family + } + + /// Parses an address string using chain-aware parsing rules. + /// + /// - `Evm`: Parses via `valid_addr_from_str` (validates EIP-55 checksum) + /// - `Tron`: Parses via `TronAddress::from_str` (accepts Base58 or hex formats) + /// + /// This centralizes parsing so call sites don't need to import `FromStr`. + pub fn from_str_with_family(s: &str, family: ChainFamily) -> Result { + match family { + ChainFamily::Evm => { + let raw = valid_addr_from_str(s)?; + Ok(Self::new(raw, ChainFamily::Evm)) + }, + ChainFamily::Tron => { + let tron_addr = TronAddress::from_str(s).map_err(|e| e.to_string())?; + Ok(Self::from(tron_addr)) + }, + } + } +} + +impl DisplayAddress for ChainTaggedAddress { + /// Formats the address according to its chain family. + /// + /// Delegates to the canonical `ChainFamily::format` method: + /// - EVM: EIP-55 mixed-case checksum format (`0xAbCd...`) + /// - TRON: Base58Check format (`T...`) + fn display_address(&self) -> String { + self.family.format(self.inner) + } +} + +/// Enables direct use in format strings: `format!("{}", tagged_address)` +impl fmt::Display for ChainTaggedAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.display_address()) + } +} + +/// **WARNING**: For `ChainTaggedAddress`, `addr_to_string()` returns USER-FACING display format +/// (EIP-55 checksum for EVM, Base58 for TRON), NOT raw lowercase hex. +/// +/// This differs from `impl AddrToString for ethereum_types::Address` which returns raw hex. +/// +/// If you need raw hex, use `tagged.inner().addr_to_string()` instead. +/// +/// This impl is required for swap negotiation/storage where addresses are serialized as strings. +impl AddrToString for ChainTaggedAddress { + fn addr_to_string(&self) -> String { + self.display_address() + } +} + +/// Converts a TRON address to a chain-tagged address. +/// Extracts the 20-byte address (dropping the 0x41 prefix) and sets family to TRON. +impl From for ChainTaggedAddress { + fn from(tron_addr: TronAddress) -> Self { + Self::from_tron(tron_addr) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Known TRON address used across all tests: + /// - Base58: TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL + /// - Full hex (21 bytes with prefix): 418840e6c55b9ada326d211d818c34a994aeced808 + /// - Raw EVM (20 bytes): 8840e6c55b9ada326d211d818c34a994aeced808 + const KNOWN_TRON_BASE58: &str = "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL"; + const KNOWN_RAW_HEX: &str = "8840e6c55b9ada326d211d818c34a994aeced808"; + + fn known_raw_address() -> EthAddress { + let bytes = hex::decode(KNOWN_RAW_HEX).unwrap(); + EthAddress::from_slice(&bytes) + } + + /// Verifies TRON address parsing and formatting with a known test vector. + /// Tests: Base58 parsing, family tagging, raw byte extraction, and roundtrip. + /// Also verifies that TronAddress::from_str accepts hex formats. + #[test] + fn tron_known_vector_roundtrip() { + // Parse from Base58 + let tagged = ChainTaggedAddress::from_str_with_family(KNOWN_TRON_BASE58, ChainFamily::Tron).unwrap(); + + // Verify family is TRON + assert_eq!(tagged.family(), ChainFamily::Tron); + + // Verify raw 20-byte address matches expected hex + assert_eq!(hex::encode(tagged.inner().as_bytes()), KNOWN_RAW_HEX); + + // Verify display_address returns original Base58 + assert_eq!(tagged.display_address(), KNOWN_TRON_BASE58); + + // Verify TronAddress::from_str also accepts hex formats (with and without 0x prefix) + let hex_with_prefix = format!("41{}", KNOWN_RAW_HEX); + let hex_0x_prefix = format!("0x41{}", KNOWN_RAW_HEX); + + let from_hex = TronAddress::from_str(&hex_with_prefix).expect("should parse hex without 0x"); + let from_hex_0x = TronAddress::from_str(&hex_0x_prefix).expect("should parse hex with 0x"); + + // Both hex forms should produce the same raw address + assert_eq!(from_hex.to_evm_address(), tagged.inner()); + assert_eq!(from_hex_0x.to_evm_address(), tagged.inner()); + } + + /// Verifies EVM address formatting and parsing roundtrip. + /// Tests: EIP-55 checksum formatting, parsing, and raw byte preservation. + #[test] + fn evm_checksum_roundtrip() { + let raw = known_raw_address(); + + // Format as EVM checksum + let formatted = ChainFamily::Evm.format(raw); + assert!(formatted.starts_with("0x"), "EVM format must start with 0x"); + + // Parse back + let tagged = ChainTaggedAddress::from_str_with_family(&formatted, ChainFamily::Evm).unwrap(); + + // Verify roundtrip preserves raw address + assert_eq!(tagged.inner(), raw); + + // Verify family is EVM + assert_eq!(tagged.family(), ChainFamily::Evm); + + // Verify formatting is stable + assert_eq!(tagged.display_address(), formatted); + } +} diff --git a/mm2src/coins/eth/chain_rpc.rs b/mm2src/coins/eth/chain_rpc.rs new file mode 100644 index 0000000000..e536e76f02 --- /dev/null +++ b/mm2src/coins/eth/chain_rpc.rs @@ -0,0 +1,175 @@ +//! Chain-agnostic RPC abstraction layer for EthCoin. +//! +//! This module provides `ChainRpcOps`, a trait that abstracts over different blockchain +//! RPC backends (EVM JSON-RPC, TRON HTTP API, etc.). The `ChainRpcClient` enum implements +//! explicit match dispatch to route calls to the appropriate backend. +//! +//! # Design Rationale +//! +//! We use an enum + explicit match pattern rather than `Deref` because: +//! - Async traits with `dyn` require boxing and have ergonomic issues +//! - Explicit matching is clearer and more maintainable +//! - Each variant can have chain-specific error types converted at the boundary +//! +//! See `docs/plans/tron-hd-activation-v2.md` Section 18 for why generic `EthCoin` isn't feasible. +//! +//! # TODO: Module Structure Refactoring +//! +//! This module should be expanded into a proper submodule structure: +//! +//! ```text +//! mm2src/coins/eth/rpc/ +//! β”œβ”€β”€ mod.rs # Re-exports, ChainRpcClient enum +//! β”œβ”€β”€ traits.rs # RpcPool trait, ChainRpcOps trait +//! β”œβ”€β”€ evm/ +//! β”‚ β”œβ”€β”€ mod.rs +//! β”‚ β”œβ”€β”€ client.rs # Single-node EVM client +//! β”‚ β”œβ”€β”€ pool.rs # EvmRpcPool (implements RpcPool) +//! β”‚ └── methods.rs # EVM-specific RPC methods +//! └── tron/ +//! β”œβ”€β”€ mod.rs +//! β”œβ”€β”€ client.rs # TronHttpClient (single node) +//! β”œβ”€β”€ pool.rs # TronRpcPool (implements RpcPool) +//! └── methods.rs # TRON-specific RPC methods +//! ``` +//! +//! See `docs/plans/chain-rpc-client-refactor.md` for the full plan. + +use async_trait::async_trait; +use ethereum_types::U256; +use mm2_err_handle::prelude::*; + +use super::tron::{TronAddress, TronApiClient}; +use super::Web3RpcError; + +// ---------------------------------------------------------------------------- +// ChainRpcOps Trait +// ---------------------------------------------------------------------------- + +/// Chain-agnostic RPC operations trait. +/// +/// Implementors provide chain-specific RPC functionality while exposing a unified interface. +/// Associated types allow each implementation to define its own error, address, and balance types. +#[async_trait] +pub trait ChainRpcOps: Send + Sync + std::fmt::Debug { + /// Chain-specific error type. + type Error; + /// Chain-specific address type. + type Address; + /// Chain-specific balance type. + type Balance; + + /// Get the current block number. + async fn current_block(&self) -> Result; + + /// Get native token balance for an address. + async fn balance_native(&self, address: Self::Address) -> Result; + + /// Check if an address has been used on-chain (basic check). + /// + /// For TRON: checks if the account exists meaningfully (has balance, create_time, or permissions). + /// For EVM: checks transaction count and balance. + async fn is_address_used_basic(&self, address: Self::Address) -> Result; +} + +// ---------------------------------------------------------------------------- +// ChainRpcClient Enum +// ---------------------------------------------------------------------------- + +/// Unified RPC client that dispatches to chain-specific implementations. +/// +/// Uses explicit match dispatch pattern for clarity and to handle async traits cleanly. +#[derive(Debug, Clone)] +pub enum ChainRpcClient { + /// TRON blockchain RPC client (uses TRON HTTP API). + Tron(TronApiClient), + /// EVM-compatible blockchain RPC client (uses JSON-RPC). + Evm(EvmRpcClient), +} + +// ---------------------------------------------------------------------------- +// EvmRpcClient Placeholder +// ---------------------------------------------------------------------------- + +/// Placeholder for EVM JSON-RPC client. +/// +/// Full implementation deferred to Phase 4. Currently EVM calls go through +/// existing EthCoin methods directly. +#[derive(Debug, Clone)] +pub struct EvmRpcClient { + // Will contain Web3 transport and node rotation logic + _placeholder: (), +} + +// ---------------------------------------------------------------------------- +// ChainRpcClient Dispatch Implementation +// ---------------------------------------------------------------------------- + +impl ChainRpcClient { + /// Get the current block number. + /// + /// Dispatches to the appropriate chain-specific implementation. + pub async fn current_block(&self) -> MmResult { + match self { + ChainRpcClient::Tron(client) => client.current_block().await.mm_err(ChainRpcError::Tron), + ChainRpcClient::Evm(_client) => { + // TODO: Phase 4 - implement EVM current_block + MmError::err(ChainRpcError::NotImplemented("EVM current_block".into())) + }, + } + } + + /// Get native token balance for an address. + /// + /// For TRON addresses, use `TronAddress`. For EVM, use `ethereum_types::Address`. + pub async fn balance_native_tron(&self, address: &TronAddress) -> MmResult { + match self { + ChainRpcClient::Tron(client) => client.balance_native(*address).await.mm_err(ChainRpcError::Tron), + ChainRpcClient::Evm(_) => MmError::err(ChainRpcError::WrongChain { + expected: "Tron", + got: "Evm", + }), + } + } + + /// Check if a TRON address has been used on-chain. + pub async fn is_address_used_tron(&self, address: &TronAddress) -> MmResult { + match self { + ChainRpcClient::Tron(client) => client.is_address_used_basic(*address).await.mm_err(ChainRpcError::Tron), + ChainRpcClient::Evm(_) => MmError::err(ChainRpcError::WrongChain { + expected: "Tron", + got: "Evm", + }), + } + } +} + +// ---------------------------------------------------------------------------- +// ChainRpcError +// ---------------------------------------------------------------------------- + +/// Unified error type for ChainRpcClient dispatch layer. +/// +/// Wraps chain-specific errors and adds dispatch-level errors. +#[derive(Debug, derive_more::Display)] +pub enum ChainRpcError { + /// TRON API error (uses Web3RpcError internally). + #[display(fmt = "TRON error: {}", _0)] + Tron(Web3RpcError), + + /// EVM RPC error. + #[display(fmt = "EVM error: {}", _0)] + Evm(Web3RpcError), + + /// Method not yet implemented. + #[display(fmt = "Not implemented: {}", _0)] + NotImplemented(String), + + /// Wrong chain type for the requested operation. + #[display(fmt = "Wrong chain: expected {}, got {}", expected, got)] + WrongChain { expected: &'static str, got: &'static str }, +} + +// NOTE: Intentionally no `impl From> for ChainRpcError`. +// Such a conversion would ambiguously map to either Tron or Evm variant. +// Callers should explicitly construct ChainRpcError::Tron or ChainRpcError::Evm. diff --git a/mm2src/coins/eth/erc20.rs b/mm2src/coins/eth/erc20.rs index 35b0a534d9..f36268545f 100644 --- a/mm2src/coins/eth/erc20.rs +++ b/mm2src/coins/eth/erc20.rs @@ -1,12 +1,11 @@ use super::{ERC20_PROTOCOL_TYPE, ETH_PROTOCOL_TYPE}; use crate::eth::web3_transport::Web3Transport; -use crate::eth::{EthCoin, ERC20_CONTRACT}; +use crate::eth::{EthCoin, Web3RpcError, ERC20_CONTRACT}; use crate::{CoinsContext, MarketCoinOps, MmCoinEnum, Ticker}; use ethabi::Token; use ethereum_types::Address; -use futures_util::TryFutureExt; use mm2_core::mm_ctx::MmArc; -use mm2_err_handle::mm_error::MmResult; +use mm2_err_handle::prelude::*; use std::str::FromStr; use web3::types::{BlockId, BlockNumber, CallRequest}; use web3::{Transport, Web3}; @@ -15,9 +14,13 @@ async fn call_erc20_function( web3: &Web3, token_addr: Address, function_name: &str, -) -> Result, String> { - let function = try_s!(ERC20_CONTRACT.function(function_name)); - let data = try_s!(function.encode_input(&[])); +) -> MmResult, Web3RpcError> { + let function = ERC20_CONTRACT + .function(function_name) + .map_to_mm(|e| Web3RpcError::Internal(e.to_string()))?; + let data = function + .encode_input(&[]) + .map_to_mm(|e| Web3RpcError::Internal(e.to_string()))?; let request = CallRequest { from: Some(Address::default()), to: Some(token_addr), @@ -31,25 +34,34 @@ async fn call_erc20_function( let res = web3 .eth() .call(request, Some(BlockId::Number(BlockNumber::Latest))) - .map_err(|e| ERRL!("{}", e)) - .await?; - function.decode_output(&res.0).map_err(|e| ERRL!("{}", e)) + .await + .map_to_mm(|e| Web3RpcError::Transport(e.to_string()))?; + function + .decode_output(&res.0) + .map_to_mm(|e| Web3RpcError::InvalidResponse(e.to_string())) } -pub(crate) async fn get_token_decimals(web3: &Web3, token_addr: Address) -> Result { +pub(crate) async fn get_token_decimals(web3: &Web3, token_addr: Address) -> MmResult { let tokens = call_erc20_function(web3, token_addr, "decimals").await?; let Some(token) = tokens.into_iter().next() else { - return ERR!("No value returned from decimals() call"); + return MmError::err(Web3RpcError::InvalidResponse( + "No value returned from decimals() call".to_string(), + )); }; let Token::Uint(dec) = token else { - return ERR!("Expected Uint token for decimals, got {:?}", token); + return MmError::err(Web3RpcError::InvalidResponse(format!( + "Expected Uint token for decimals, got {:?}", + token + ))); }; Ok(dec.as_u64() as u8) } async fn get_token_symbol(coin: &EthCoin, token_addr: Address) -> Result { let web3 = try_s!(coin.web3().await); - let tokens = call_erc20_function(&web3, token_addr, "symbol").await?; + let tokens = call_erc20_function(&web3, token_addr, "symbol") + .await + .map_err(|e| e.to_string())?; let Some(token) = tokens.into_iter().next() else { return ERR!("No value returned from symbol() call"); }; @@ -68,7 +80,7 @@ pub struct Erc20TokenInfo { pub async fn get_erc20_token_info(coin: &EthCoin, token_addr: Address) -> Result { let symbol = get_token_symbol(coin, token_addr).await?; let web3 = try_s!(coin.web3().await); - let decimals = get_token_decimals(&web3, token_addr).await?; + let decimals = get_token_decimals(&web3, token_addr).await.map_err(|e| e.to_string())?; Ok(Erc20TokenInfo { symbol, decimals }) } diff --git a/mm2src/coins/eth/eth_balance_events.rs b/mm2src/coins/eth/eth_balance_events.rs index 816150fe83..a082780364 100644 --- a/mm2src/coins/eth/eth_balance_events.rs +++ b/mm2src/coins/eth/eth_balance_events.rs @@ -1,7 +1,7 @@ use super::EthCoin; use crate::{ - eth::{u256_to_big_decimal, Erc20TokenDetails}, - hd_wallet::AddrToString, + eth::{u256_to_big_decimal, ChainTaggedAddress, Erc20TokenDetails}, + hd_wallet::DisplayAddress, BalanceError, CoinWithDerivationMethod, }; use common::{executor::Timer, log, Future01CompatExt}; @@ -67,7 +67,10 @@ type BalanceResult = Result; /// This implementation differs from others, as they immediately return /// an error if any of the requests fails. This one completes all futures /// and returns their results individually. -async fn get_all_balance_results_concurrently(coin: &EthCoin, addresses: HashSet
) -> Vec { +async fn get_all_balance_results_concurrently( + coin: &EthCoin, + addresses: HashSet, +) -> Vec { let mut tokens = coin.get_erc_tokens_infos(); // Workaround for performance purposes. // @@ -106,10 +109,11 @@ async fn get_all_balance_results_concurrently(coin: &EthCoin, addresses: HashSet async fn fetch_balance( coin: &EthCoin, - address: Address, + address: ChainTaggedAddress, token_ticker: String, info: &Erc20TokenDetails, ) -> Result { + let address_str = address.display_address(); let (balance_as_u256, decimals) = if token_ticker == coin.ticker { ( coin.address_balance(address) @@ -117,18 +121,18 @@ async fn fetch_balance( .await .map_err(|error| BalanceFetchError { ticker: token_ticker.clone(), - address: address.addr_to_string(), + address: address_str.clone(), error, })?, coin.decimals, ) } else { ( - coin.get_token_balance(info.token_address) + coin.get_token_balance_for_address(address.inner(), info.token_address) .await .map_err(|error| BalanceFetchError { ticker: token_ticker.clone(), - address: address.addr_to_string(), + address: address_str.clone(), error, })?, info.decimals, @@ -137,13 +141,13 @@ async fn fetch_balance( let balance_as_big_decimal = u256_to_big_decimal(balance_as_u256, decimals).map_err(|e| BalanceFetchError { ticker: token_ticker.clone(), - address: address.addr_to_string(), + address: address_str.clone(), error: e.map(BalanceError::from), })?; Ok(BalanceData { ticker: token_ticker, - address: address.addr_to_string(), + address: address_str, balance: balance_as_big_decimal, }) } diff --git a/mm2src/coins/eth/eth_hd_wallet.rs b/mm2src/coins/eth/eth_hd_wallet.rs index 46bea6d52b..2876ae8d94 100644 --- a/mm2src/coins/eth/eth_hd_wallet.rs +++ b/mm2src/coins/eth/eth_hd_wallet.rs @@ -1,3 +1,4 @@ +use super::chain_address::ChainTaggedAddress; use super::*; use crate::coin_balance::HDAddressBalanceScanner; use crate::hd_wallet::{ @@ -6,20 +7,12 @@ use crate::hd_wallet::{ use async_trait::async_trait; use bip32::DerivationPath; use crypto::Secp256k1ExtendedPublicKey; -use ethereum_types::{Address, Public}; +use ethereum_types::Public; -pub type EthHDAddress = HDAddress; +pub type EthHDAddress = HDAddress; pub type EthHDAccount = HDAccount; pub type EthHDWallet = HDWallet; -impl DisplayAddress for Address { - /// converts `Address` to mixed-case checksum form. - #[inline] - fn display_address(&self) -> String { - checksum_address(&self.addr_to_string()) - } -} - #[async_trait] impl ExtractExtendedPubkey for EthCoin { type ExtendedPublicKey = Secp256k1ExtendedPublicKey; @@ -46,9 +39,11 @@ impl HDWalletCoinOps for EthCoin { derivation_path: DerivationPath, ) -> EthHDAddress { let pubkey = pubkey_from_extended(extended_pubkey); - let address = public_to_address(&pubkey); + let raw = public_to_address(&pubkey); + let family = ChainFamily::from(&self.0.chain_spec); + EthHDAddress { - address, + address: ChainTaggedAddress::new(raw, family), pubkey, derivation_path, } @@ -68,28 +63,92 @@ impl HDCoinWithdrawOps for EthCoin {} #[async_trait] #[cfg_attr(test, mockable)] impl HDAddressBalanceScanner for EthCoin { - type Address = Address; + type Address = ChainTaggedAddress; async fn is_address_used(&self, address: &Self::Address) -> BalanceResult { - // Count calculates the number of transactions sent from the address whether it's for ERC20 or ETH. - // If the count is greater than 0, then the address is used. - // If the count is 0, then we check for the balance of the address to make sure there was no received transactions. - let count = self.transaction_count(*address, None).await?; - if count > U256::zero() { - return Ok(true); + // TODO: Once EVM is migrated to ChainRpcClient, this can use a unified + // `rpc_client.is_address_used_basic(address)` call without chain_spec matching. + // See docs/plans/chain-rpc-client-refactor.md Section 17. + let raw = address.inner(); + match &self.0.chain_spec { + ChainSpec::Tron { .. } => { + // TRON address usage detection. + // + // We use AccountCapsule existence check instead of transaction count because: + // - TRON doesn't have an account nonce like Ethereum - it uses TAPOS (Transaction + // as Proof of Stake) with reference blocks for replay protection instead + // - There's no `eth_getTransactionCount` equivalent on TRON (it throws an error) + // - TRON requires an AccountCapsule to exist before ANY transaction can be made + // - AccountCapsule check is a single efficient API call + // + // Note on Gas-Free: TRON's "Gas-Free" USDT transfers (paying fees in USDT instead + // of TRX) still require the sender's account to be activated first. + // + // TODO: EIP-2612 permit edge case (applies to both EVM and TRON): + // If a token implements permit, an address can receive tokens, sign offline, and + // have a relayer call transferFrom() - without the owner making any transaction. + // After tokens are transferred out, the address appears unused: + // - EVM: account nonce = 0 (eth_getTransactionCount only counts OUTGOING txs) + // - TRON: no AccountCapsule, balance = 0 + // The token contract stores a separate `nonces(owner)` that tracks permit usage, + // but checking it requires RPC calls to each permit-enabled token contract. + // Mainstream tokens (USDT, etc.) don't implement permit, so this is rare. + let tron_client = self + .0 + .tron_rpc() + .ok_or_else(|| BalanceError::Internal("TRON chain_spec but no TRON rpc_client".to_string()))?; + let tron_addr = tron::TronAddress::from(raw); + + // First check: on-chain account existence via /wallet/getaccount API. + // Returns true if the account's on-chain record (TRON calls this "AccountCapsule") + // exists. Created when the address receives TRX or TRC10 tokens. + if tron_client.is_address_used_basic(tron_addr).await.map_mm_err()? { + return Ok(true); + } + + // Second check: TRC20 token balances for user-configured tokens. + // + // Edge case: TRC20 tokens can exist even if the account isn't activated on-chain. + // Unlike TRX/TRC10, TRC20 balances are stored in the token contract's internal + // mapping (not in the account's on-chain record), so an address can hold TRC20 tokens before + // ever receiving TRX. + // + // This can happen when: + // - Someone sends real tokens (USDT, etc.) to a new address before it's activated + // - Legitimate project airdrops to addresses that haven't been used yet + // + // Note: We only query tokens the user has explicitly configured, so spam/phishing + // tokens (address poisoning attacks) won't trigger false positives here. + let token_balance_map = self.get_tokens_balance_list_for_address(raw).await?; + Ok(token_balance_map.values().any(|balance| !balance.get_total().is_zero())) + }, + ChainSpec::Evm { .. } => { + // EVM path: `eth_getTransactionCount` returns the account nonce - the number of + // OUTGOING transactions sent FROM this address (not incoming, not contract calls + // made by others). If count > 0, the address has sent at least one transaction. + // If count = 0, we fall back to balance checks to detect received-only addresses. + // + // Note: This misses the EIP-2612 permit edge case (see TODO in TRON branch above). + // The token contract's `nonces(owner)` is separate from this account nonce. + let count = self.transaction_count(raw, None).await?; + if count > U256::zero() { + return Ok(true); + } + + // We check for platform balance only first to reduce the number of requests to the node. + // If this is a token added using init_token, then we check for this token balance only, and + // we don't check for platform balance or other tokens that was added before. + let platform_balance = self.address_balance(*address).compat().await?; + if !platform_balance.is_zero() { + return Ok(true); + } + + // This is done concurrently which increases the cost of the requests to the node. + // But it's better than doing it sequentially to reduce the time. + let token_balance_map = self.get_tokens_balance_list_for_address(raw).await?; + Ok(token_balance_map.values().any(|balance| !balance.get_total().is_zero())) + }, } - - // We check for platform balance only first to reduce the number of requests to the node. - // If this is a token added using init_token, then we check for this token balance only, and - // we don't check for platform balance or other tokens that was added before. - let platform_balance = self.address_balance(*address).compat().await?; - if !platform_balance.is_zero() { - return Ok(true); - } - - // This is done concurrently which increases the cost of the requests to the node. but it's better than doing it sequentially to reduce the time. - let token_balance_map = self.get_tokens_balance_list_for_address(*address).await?; - Ok(token_balance_map.values().any(|balance| !balance.get_total().is_zero())) } } @@ -146,7 +205,7 @@ impl HDWalletBalanceOps for EthCoin { .await } - async fn known_address_balance(&self, address: &Address) -> BalanceResult { + async fn known_address_balance(&self, address: &ChainTaggedAddress) -> BalanceResult { let balance = self .address_balance(*address) .and_then(move |result| u256_to_big_decimal(result, self.decimals()).map_mm_err()) @@ -160,15 +219,16 @@ impl HDWalletBalanceOps for EthCoin { let mut balances = CoinBalanceMap::new(); balances.insert(self.ticker().to_string(), coin_balance); - let token_balances = self.get_tokens_balance_list_for_address(*address).await?; + + let token_balances = self.get_tokens_balance_list_for_address(address.inner()).await?; balances.extend(token_balances); Ok(balances) } async fn known_addresses_balances( &self, - addresses: Vec
, - ) -> BalanceResult> { + addresses: Vec, + ) -> BalanceResult> { let mut balance_futs = Vec::new(); for address in addresses { let fut = async move { diff --git a/mm2src/coins/eth/eth_rpc.rs b/mm2src/coins/eth/eth_rpc.rs index 1df357242b..88bd7b91c5 100644 --- a/mm2src/coins/eth/eth_rpc.rs +++ b/mm2src/coins/eth/eth_rpc.rs @@ -1,6 +1,12 @@ //! This module serves as an abstraction layer for Ethereum RPCs. //! Unlike the built-in functions in web3, this module dynamically //! rotates through all transports in case of failures. +//! +//! # TODO: RPC Pool Trait Refactoring +//! +//! The `try_rpc_send` pattern here is duplicated in TRON's `try_clients` (`tron/api.rs`). +//! Both should implement a common `RpcPool` trait with associated types for Client and Error. +//! See `docs/plans/chain-rpc-client-refactor.md` for the full refactoring plan. use super::web3_transport::FeeHistoryResult; use super::{web3_transport::Web3Transport, EthCoin}; @@ -155,7 +161,7 @@ impl EthCoin { .and_then(|t| serde_json::from_value(t).map_err(Into::into)) } - /// Get balance of given address + /// Get balance of given address. pub(crate) async fn balance(&self, address: Address, block: Option) -> Result { let address = helpers::serialize(&address); let block = helpers::serialize(&block.unwrap_or(BlockNumber::Latest)); diff --git a/mm2src/coins/eth/eth_swap_v2/eth_maker_swap_v2.rs b/mm2src/coins/eth/eth_swap_v2/eth_maker_swap_v2.rs index 023c97653f..da299d99d5 100644 --- a/mm2src/coins/eth/eth_swap_v2/eth_maker_swap_v2.rs +++ b/mm2src/coins/eth/eth_swap_v2/eth_maker_swap_v2.rs @@ -107,7 +107,8 @@ impl EthCoin { .await }, EthCoinType::Nft { .. } => Err(TransactionErr::ProtocolNotSupported(ERRL!( - "NFT protocol is not supported for ETH and ERC20 Swaps" + "{} protocol is not supported for ETH and ERC20 Swaps", + self.coin_type ))), } } @@ -135,8 +136,9 @@ impl EthCoin { let swap_id = self.etomic_swap_id_v2(args.time_lock, args.maker_secret_hash); let tx = args.maker_payment_tx; - let maker_address = public_to_address(args.maker_pub); - validate_from_to_addresses(tx, maker_address, maker_swap_v2_contract).map_mm_err()?; + let maker_address = self.tag_address(public_to_address(args.maker_pub)); + let contract_tagged = self.tag_address(maker_swap_v2_contract); + validate_from_to_addresses(tx, maker_address, contract_tagged).map_mm_err()?; let validation_args = { let amount = u256_from_big_decimal(&args.amount, self.decimals).map_mm_err()?; @@ -162,9 +164,10 @@ impl EthCoin { validate_erc20_maker_payment_data(&decoded, &validation_args, function, token_addr)?; }, EthCoinType::Nft { .. } => { - return MmError::err(ValidatePaymentError::ProtocolNotSupported( - "NFT protocol is not supported for ETH and ERC20 Swaps".to_string(), - )); + return MmError::err(ValidatePaymentError::ProtocolNotSupported(format!( + "{} protocol is not supported for ETH and ERC20 Swaps", + self.coin_type + ))); }, } diff --git a/mm2src/coins/eth/eth_swap_v2/eth_taker_swap_v2.rs b/mm2src/coins/eth/eth_swap_v2/eth_taker_swap_v2.rs index c7c13b6619..395525d5b6 100644 --- a/mm2src/coins/eth/eth_swap_v2/eth_taker_swap_v2.rs +++ b/mm2src/coins/eth/eth_swap_v2/eth_taker_swap_v2.rs @@ -139,7 +139,8 @@ impl EthCoin { .await }, EthCoinType::Nft { .. } => Err(TransactionErr::ProtocolNotSupported(ERRL!( - "NFT protocol is not supported for ETH and ERC20 Swaps" + "{} protocol is not supported for ETH and ERC20 Swaps", + self.coin_type ))), } } @@ -165,8 +166,9 @@ impl EthCoin { let swap_id = self.etomic_swap_id_v2(args.payment_time_lock, args.maker_secret_hash); let tx = args.funding_tx; - let taker_address = public_to_address(args.taker_pub); - validate_from_to_addresses(tx, taker_address, taker_swap_v2_contract).map_mm_err()?; + let taker_address = self.tag_address(public_to_address(args.taker_pub)); + let contract_tagged = self.tag_address(taker_swap_v2_contract); + validate_from_to_addresses(tx, taker_address, contract_tagged).map_mm_err()?; let validation_args = { let dex_fee = u256_from_big_decimal(&args.dex_fee.fee_amount().into(), self.decimals).map_mm_err()?; @@ -195,9 +197,10 @@ impl EthCoin { validate_erc20_taker_payment_data(&decoded, &validation_args, function, token_addr)?; }, EthCoinType::Nft { .. } => { - return MmError::err(ValidateSwapV2TxError::ProtocolNotSupported( - "NFT protocol is not supported for ETH and ERC20 Swaps".to_string(), - )); + return MmError::err(ValidateSwapV2TxError::ProtocolNotSupported(format!( + "{} protocol is not supported for ETH and ERC20 Swaps", + self.coin_type + ))); }, } Ok(()) @@ -213,7 +216,8 @@ impl EthCoin { EthCoinType::Eth | EthCoinType::Erc20 { .. } => U256::from(self.gas_limit_v2.taker.approve_payment), EthCoinType::Nft { .. } => { return Err(TransactionErr::ProtocolNotSupported(ERRL!( - "NFT protocol is not supported for ETH and ERC20 Swaps" + "{} protocol is not supported for ETH and ERC20 Swaps", + self.coin_type ))) }, }; @@ -562,9 +566,10 @@ impl EthCoin { ])? }, EthCoinType::Nft { .. } => { - return Err(PrepareTxDataError::Internal( - "NFT protocol is not supported for ETH and ERC20 Swaps".into(), - )) + return Err(PrepareTxDataError::Internal(format!( + "{} protocol is not supported for ETH and ERC20 Swaps", + self.coin_type + ))) }, }; Ok(data) @@ -608,9 +613,10 @@ impl EthCoin { ])?; Ok(data) }, - EthCoinType::Nft { .. } => Err(PrepareTxDataError::Internal( - "NFT protocol is not supported for ETH and ERC20 Swaps".to_string(), - )), + EthCoinType::Nft { .. } => Err(PrepareTxDataError::Internal(format!( + "{} protocol is not supported for ETH and ERC20 Swaps", + self.coin_type + ))), } } @@ -628,7 +634,8 @@ impl EthCoin { EthCoinType::Erc20 { token_addr, .. } => (try_tx_s!(TAKER_SWAP_V2.function(erc20_func_name)), token_addr), EthCoinType::Nft { .. } => { return Err(TransactionErr::ProtocolNotSupported(ERRL!( - "NFT protocol is not supported for ETH and ERC20 Swaps" + "{} protocol is not supported for ETH and ERC20 Swaps", + self.coin_type ))) }, }; @@ -649,9 +656,10 @@ impl EthCoin { EthCoinType::Eth => TAKER_SWAP_V2.function(ETH_TAKER_PAYMENT)?, EthCoinType::Erc20 { .. } => TAKER_SWAP_V2.function(ERC20_TAKER_PAYMENT)?, EthCoinType::Nft { .. } => { - return Err(PrepareTxDataError::Internal( - "NFT protocol is not supported for ETH and ERC20 Swaps".to_string(), - )); + return Err(PrepareTxDataError::Internal(format!( + "{} protocol is not supported for ETH and ERC20 Swaps", + self.coin_type + ))); }, }; decode_contract_call(func, tx.unsigned().data())? diff --git a/mm2src/coins/eth/eth_swap_v2/mod.rs b/mm2src/coins/eth/eth_swap_v2/mod.rs index ad01de3b29..d452f84e13 100644 --- a/mm2src/coins/eth/eth_swap_v2/mod.rs +++ b/mm2src/coins/eth/eth_swap_v2/mod.rs @@ -1,3 +1,4 @@ +use crate::eth::chain_address::ChainTaggedAddress; use crate::eth::{decode_contract_call, signed_tx_from_web3_tx, EthCoin, EthCoinType, Transaction, TransactionErr}; use crate::hd_wallet::DisplayAddress; use crate::{FindPaymentSpendError, MarketCoinOps}; @@ -76,7 +77,10 @@ impl EthCoin { match &self.coin_type { EthCoinType::Eth => Ok(Address::default()), EthCoinType::Erc20 { token_addr, .. } => Ok(*token_addr), - EthCoinType::Nft { .. } => Err("NFT protocol is not supported for ETH and ERC20 Swaps".to_string()), + EthCoinType::Nft { .. } => Err(format!( + "{} protocol is not supported for ETH and ERC20 Swaps", + self.coin_type + )), } } @@ -175,26 +179,34 @@ impl EthCoin { } } +/// Validates that a signed transaction has the expected from and to addresses. +/// +/// Uses `ChainTaggedAddress` to ensure chain-aware formatting in error messages +/// (EVM checksum for EVM chains, Base58 for TRON). pub(crate) fn validate_from_to_addresses( signed_tx: &SignedEthTx, - expected_from: Address, - expected_to: Address, + expected_from: ChainTaggedAddress, + expected_to: ChainTaggedAddress, ) -> Result<(), MmError> { - if signed_tx.sender() != expected_from { + let family = expected_from.family(); + let actual_from = signed_tx.sender(); + + if actual_from != expected_from.inner() { return MmError::err(ValidatePaymentV2Err::WrongPaymentTx(format!( - "Payment tx {signed_tx:?} was sent from wrong address, expected {}", - expected_from.display_address() + "Payment tx {signed_tx:?} was sent from wrong address, expected {}, got {}", + expected_from.display_address(), + family.format(actual_from) ))); } // (in NFT case) as NFT owner calls "safeTransferFrom" directly, then in Transaction 'to' field we expect token_address match signed_tx.unsigned().action() { - Action::Call(to) => { - if *to != expected_to { + Action::Call(actual_to) => { + if *actual_to != expected_to.inner() { return MmError::err(ValidatePaymentV2Err::WrongPaymentTx(format!( "Payment tx was sent to wrong address, expected {}, got {}", expected_to.display_address(), - to.display_address() + family.format(*actual_to) ))); } }, diff --git a/mm2src/coins/eth/eth_tests.rs b/mm2src/coins/eth/eth_tests.rs index b4cea9f129..69d7c0f186 100644 --- a/mm2src/coins/eth/eth_tests.rs +++ b/mm2src/coins/eth/eth_tests.rs @@ -901,7 +901,7 @@ fn test_eth_extract_secret() { 100, 189, 72, 74, 221, 144, 66, 170, 68, 121, 29, 105, 19, 194, 35, 245, 196, 131, 236, 29, 105, 101, 30, ]; - let secret = block_on(coin.extract_secret(&[0u8; 20], tx_bytes.as_slice(), false)); + let secret = block_on(coin.extract_secret(&[0u8; 20], tx_bytes.as_slice())); assert!(secret.is_ok()); let expect_secret = &[ 168, 151, 11, 232, 224, 253, 63, 180, 26, 114, 23, 184, 27, 10, 161, 80, 178, 251, 73, 204, 80, 174, 97, 118, @@ -924,10 +924,10 @@ fn test_eth_extract_secret() { 6, 108, 165, 181, 188, 40, 56, 47, 211, 229, 221, 73, 5, 15, 89, 81, 117, 225, 216, 108, 98, 226, 119, 232, 94, 184, 42, 106, ]; - let secret = block_on(coin.extract_secret(&[0u8; 20], tx_bytes.as_slice(), false)) + let secret = block_on(coin.extract_secret(&[0u8; 20], tx_bytes.as_slice())) .err() .unwrap(); - assert!(secret.contains("Expected 'receiverSpend' contract call signature")); + assert!(secret.contains("Transaction is not a receiverSpend call")); } #[test] diff --git a/mm2src/coins/eth/eth_withdraw.rs b/mm2src/coins/eth/eth_withdraw.rs index e35a090799..6370d7a068 100644 --- a/mm2src/coins/eth/eth_withdraw.rs +++ b/mm2src/coins/eth/eth_withdraw.rs @@ -1,18 +1,24 @@ use super::{ - checksum_address, u256_from_big_decimal, u256_to_big_decimal, ChainSpec, EthCoinType, EthDerivationMethod, - EthPrivKeyPolicy, Public, WithdrawError, WithdrawRequest, WithdrawResult, ERC20_CONTRACT, H160, H256, + u256_from_big_decimal, u256_to_big_decimal, ChainSpec, ChainTaggedAddress, EthCoinType, EthDerivationMethod, + EthPrivKeyPolicy, Public, WithdrawError, WithdrawRequest, WithdrawResult, ERC20_CONTRACT, H256, }; +use crate::eth::tron::sign::sign_tron_transaction; +use crate::eth::tron::withdraw::{ + build_tron_trc20_withdraw, build_tron_trx_withdraw, validate_tron_fee_policy, TronWithdrawContext, +}; +use crate::eth::tron::TronAddress; use crate::eth::wallet_connect::WcEthTxParams; use crate::eth::{ calc_total_fee, get_eth_gas_details_from_withdraw_fee, tx_builder_with_pay_for_gas_option, - tx_type_from_pay_for_gas_option, Action, Address, EthTxFeeDetails, KeyPair, PayForGasOption, SignedEthTx, + tx_type_from_pay_for_gas_option, Action, EthTxFeeDetails, KeyPair, PayForGasOption, SignedEthTx, TransactionWrapper, UnSignedEthTxBuilder, ETH_RPC_REQUEST_TIMEOUT_S, }; -use crate::hd_wallet::{HDAddressSelector, HDCoinWithdrawOps, HDWalletOps, WithdrawSenderAddress}; +use crate::hd_wallet::{DisplayAddress, HDAddressSelector, HDCoinWithdrawOps, HDWalletOps, WithdrawSenderAddress}; use crate::rpc_command::init_withdraw::{WithdrawInProgressStatus, WithdrawTaskHandleShared}; +use crate::BigDecimal; use crate::{ BytesJson, CoinWithDerivationMethod, EthCoin, GetWithdrawSenderAddress, PrivKeyPolicy, TransactionData, - TransactionDetails, + TransactionDetails, TxFeeDetails, }; use async_trait::async_trait; use bip32::DerivationPath; @@ -28,11 +34,18 @@ use mm2_core::mm_ctx::MmArc; use mm2_err_handle::map_mm_error::MapMmError; use mm2_err_handle::mm_error::MmResult; use mm2_err_handle::prelude::{MapToMmResult, MmError, MmResultExt, OrMmError}; +use prost::Message; use std::ops::Deref; use std::sync::Arc; #[cfg(target_arch = "wasm32")] use web3::types::TransactionRequest; +/// Format an `H256` tx hash as a lowercase hex string. +fn format_tx_hash(hash: H256) -> String { + let bytes = BytesJson::from(hash.0.to_vec()); + format!("{bytes:02x}") +} + /// `EthWithdraw` trait provides methods for withdrawing Ethereum and ERC20 tokens. /// This allows different implementations of withdrawal logic for different types of wallets. #[async_trait] @@ -61,8 +74,56 @@ where unsigned_tx: &TransactionWrapper, ) -> Result>; - /// Transforms the `from` parameter of the withdrawal request into an address. - async fn get_from_address(&self, req: &WithdrawRequest) -> Result> { + /// Assembles the final `TransactionDetails` from a signed transaction. + /// + /// Shared by both EVM and TRON withdraw paths to avoid duplicating the + /// spent/received calculation and struct construction. + #[allow(clippy::result_large_err)] + fn build_transaction_details( + &self, + from_tagged: &ChainTaggedAddress, + to_tagged: &ChainTaggedAddress, + tx: TransactionData, + amount: ethabi::ethereum_types::U256, + total_fee: &BigDecimal, + fee_details: TxFeeDetails, + ) -> WithdrawResult { + let coin = self.coin(); + + let amount_decimal = u256_to_big_decimal(amount, coin.decimals).map_mm_err()?; + let mut spent_by_me = amount_decimal.clone(); + let received_by_me = if to_tagged.inner() == from_tagged.inner() { + amount_decimal.clone() + } else { + 0.into() + }; + // For native coins (ETH / TRX), the fee is paid from the same balance. + if coin.coin_type == EthCoinType::Eth { + spent_by_me += total_fee; + } + + Ok(TransactionDetails { + to: vec![to_tagged.display_address()], + from: vec![from_tagged.display_address()], + total_amount: amount_decimal, + my_balance_change: &received_by_me - &spent_by_me, + spent_by_me, + received_by_me, + tx, + block_height: 0, + fee_details: Some(fee_details), + coin: coin.ticker.clone(), + internal_id: vec![].into(), + timestamp: now_sec(), + kmd_rewards: None, + transaction_type: Default::default(), + memo: None, + }) + } + + /// - This returns `ChainTaggedAddress` so user-facing formatting is always chain-aware (EVM checksum vs TRON base58). + /// - Convert to raw with `.inner()` when performing RPC calls / tx building. + async fn get_from_address(&self, req: &WithdrawRequest) -> Result> { let coin = self.coin(); match req.from { Some(_) => Ok(coin.get_withdraw_sender_address(req).await.map_mm_err()?.address), @@ -146,15 +207,11 @@ where match coin.priv_key_policy { EthPrivKeyPolicy::Iguana(_) | EthPrivKeyPolicy::HDWallet { .. } => { let key_pair = self.get_key_pair(req)?; - let chain_id = match coin.chain_spec { - ChainSpec::Evm { chain_id } => chain_id, - // Todo: Tron have different transaction signing algorithm, we should probably have a trait abstracting both - ChainSpec::Tron { .. } => { - return MmError::err(WithdrawError::InternalError( - "Tron is not supported for withdraw yet".to_owned(), - )) - }, - }; + let chain_id = coin.chain_spec.chain_id().ok_or_else(|| { + WithdrawError::InternalError( + "sign_withdraw_tx must not be called for TRON; use TRON branch in build()".to_owned(), + ) + })?; let signed = unsigned_tx.sign(key_pair.secret(), Some(chain_id))?; let bytes = rlp::encode(&signed); @@ -218,20 +275,159 @@ where } } + /// Builds a TRON withdrawal transaction. + /// + /// Handles the full TRON withdraw pipeline: fee policy validation, TRON RPC calls + /// for TAPOS + resources + prices, tx building, signing, and fee details construction. + async fn build_tron_withdraw( + &self, + from_tagged: ChainTaggedAddress, + to_tagged: ChainTaggedAddress, + ) -> WithdrawResult { + let coin = self.coin(); + let ticker = &coin.ticker; + let req = self.request(); + + // 1. Validate TRON-specific request constraints + validate_tron_fee_policy(&req.fee)?; + if req.memo.is_some() { + return MmError::err(WithdrawError::UnsupportedError( + "Memo is not yet supported for TRON withdraw (TRON charges 1 TRX burn fee for memo)".to_owned(), + )); + } + + // 2. Validate key policy (only Iguana/HDWallet supported for TRON MVP) + match coin.priv_key_policy { + EthPrivKeyPolicy::Iguana(_) | EthPrivKeyPolicy::HDWallet { .. } => {}, + EthPrivKeyPolicy::Trezor => { + return MmError::err(WithdrawError::UnsupportedError( + "Trezor is not supported for TRON withdraw".to_owned(), + )) + }, + EthPrivKeyPolicy::WalletConnect { .. } => { + return MmError::err(WithdrawError::UnsupportedError( + "WalletConnect is not supported for TRON withdraw".to_owned(), + )) + }, + #[cfg(target_arch = "wasm32")] + EthPrivKeyPolicy::Metamask(_) => { + return MmError::err(WithdrawError::UnsupportedError( + "MetaMask is not supported for TRON withdraw".to_owned(), + )) + }, + } + + // 3. Get TRON RPC client and fetch balance + let tron = coin + .0 + .tron_rpc() + .ok_or_else(|| WithdrawError::InternalError("TRON RPC client is not initialized".to_owned()))?; + + let my_balance = coin.address_balance(from_tagged).compat().await.map_mm_err()?; + let my_balance_dec = u256_to_big_decimal(my_balance, coin.decimals).map_mm_err()?; + + if req.max && my_balance.is_zero() { + return MmError::err(WithdrawError::ZeroBalanceToWithdrawMax); + } + + let amount_base_units = if req.max { + my_balance + } else { + let amount = u256_from_big_decimal(&req.amount, coin.decimals).map_mm_err()?; + if amount > my_balance { + let required_dec = u256_to_big_decimal(amount, coin.decimals).map_mm_err()?; + return MmError::err(WithdrawError::NotSufficientBalance { + coin: ticker.to_owned(), + available: my_balance_dec, + required: required_dec, + }); + } + amount + }; + + // 4. Convert addresses to TRON format + let from_tron = TronAddress::from(from_tagged.inner()); + let to_tron = TronAddress::from(to_tagged.inner()); + + // 5. Fetch TAPOS block data (timestamp/expiration are derived inside the tx builders). + let block_data = tron.get_block_for_tapos().await.map_mm_err()?; + + // 6. Fetch account resources and chain prices. + let resources = tron.get_account_resource(&from_tron).await.map_mm_err()?; + let prices = tron.get_chain_prices().await.map_mm_err()?; + + // 7. Build tx, estimate fees β€” branching on coin type + let withdraw_ctx = TronWithdrawContext { + from: &from_tron, + to: &to_tron, + block_data: &block_data, + resources, + prices, + fee_coin: ticker, + expiration_seconds: req.expiration_seconds, + }; + let (raw, tron_fee_details, final_amount) = match &coin.coin_type { + EthCoinType::Eth => { + build_tron_trx_withdraw(&withdraw_ctx, amount_base_units, my_balance, &my_balance_dec, req.max)? + }, + EthCoinType::Erc20 { + token_addr, platform, .. + } => { + let contract_tron = TronAddress::from(*token_addr); + let trc20_ctx = TronWithdrawContext { + fee_coin: platform.as_str(), + ..withdraw_ctx + }; + build_tron_trc20_withdraw(&trc20_ctx, tron, &contract_tron, amount_base_units).await? + }, + EthCoinType::Nft { .. } => { + return MmError::err(WithdrawError::ProtocolNotSupported( + "NFT withdraw is not supported for TRON".to_owned(), + )) + }, + }; + + // 8. Sign the transaction + let key_pair = self.get_key_pair(req)?; + let (tx_hash, signed_tx) = + sign_tron_transaction(&raw, key_pair.secret()).map_to_mm(|e| WithdrawError::SigningError(e.to_string()))?; + let signed_tx_bytes = BytesJson::from(signed_tx.encode_to_vec()); + + // 9. Build TransactionDetails + self.on_finishing()?; + let tx = TransactionData::new_signed(signed_tx_bytes, format_tx_hash(tx_hash)); + let total_fee = tron_fee_details.total_fee.clone(); + self.build_transaction_details( + &from_tagged, + &to_tagged, + tx, + final_amount, + &total_fee, + tron_fee_details.into(), + ) + } + /// Builds the withdrawal transaction and returns the transaction details. async fn build(self) -> WithdrawResult { let coin = self.coin(); - let ticker = coin.deref().ticker.clone(); + let ticker = coin.ticker.clone(); let req = self.request().clone(); - let to_addr = coin + let to_tagged = coin .address_from_str(&req.to) .map_to_mm(WithdrawError::InvalidAddress)?; - let my_address = self.get_from_address(&req).await?; + let from_tagged = self.get_from_address(&req).await?; self.on_generating_transaction()?; - let my_balance = coin.address_balance(my_address).compat().await.map_mm_err()?; + // ── TRON withdraw: early-return branch ── + // TRON uses TAPOS + protobuf + bandwidth/energy fees instead of nonce + RLP + gas. + if let ChainSpec::Tron { .. } = coin.chain_spec { + return self.build_tron_withdraw(from_tagged, to_tagged).await; + } + + // ── EVM withdraw: existing path (unchanged) ── + let my_balance = coin.address_balance(from_tagged).compat().await.map_mm_err()?; let my_balance_dec = u256_to_big_decimal(my_balance, coin.decimals).map_mm_err()?; let (mut wei_amount, dec_amount) = if req.max { @@ -248,13 +444,18 @@ where }); }; let (mut eth_value, data, call_addr, fee_coin) = match &coin.coin_type { - EthCoinType::Eth => (wei_amount, vec![], to_addr, ticker.as_str()), + EthCoinType::Eth => (wei_amount, vec![], to_tagged.inner(), ticker.as_str()), EthCoinType::Erc20 { platform, token_addr } => { let function = ERC20_CONTRACT.function("transfer")?; - let data = function.encode_input(&[Token::Address(to_addr), Token::Uint(wei_amount)])?; + let data = function.encode_input(&[Token::Address(to_tagged.inner()), Token::Uint(wei_amount)])?; (0.into(), data, *token_addr, platform.as_str()) }, - EthCoinType::Nft { .. } => return MmError::err(WithdrawError::NftProtocolNotSupported), + EthCoinType::Nft { .. } => { + return MmError::err(WithdrawError::ProtocolNotSupported(format!( + "{} protocol is not supported", + coin.coin_type + ))) + }, }; let eth_value_dec = u256_to_big_decimal(eth_value, coin.decimals).map_mm_err()?; @@ -263,7 +464,7 @@ where req.fee.clone(), eth_value, data.clone().into(), - my_address, + from_tagged.inner(), call_addr, req.max, ) @@ -287,11 +488,11 @@ where let (tx_hash, tx_hex) = match coin.priv_key_policy { EthPrivKeyPolicy::Iguana(_) | EthPrivKeyPolicy::HDWallet { .. } | EthPrivKeyPolicy::Trezor => { - let address_lock = coin.get_address_lock(my_address).await; + let address_lock = coin.get_address_lock(from_tagged.inner()).await; let _nonce_lock = address_lock.lock().await; let (nonce, _) = coin .clone() - .get_addr_nonce(my_address) + .get_addr_nonce(from_tagged.inner()) .compat() .timeout(ETH_RPC_REQUEST_TIMEOUT_S) .await? @@ -314,8 +515,8 @@ where let gas_price = pay_for_gas_option.get_gas_price(); let (max_fee_per_gas, max_priority_fee_per_gas) = pay_for_gas_option.get_fee_per_gas(); let tx_to_send = TransactionRequest { - from: my_address, - to: Some(to_addr), + from: from_tagged.inner(), + to: Some(to_tagged.inner()), gas: Some(gas), gas_price, max_fee_per_gas, @@ -341,7 +542,7 @@ where // TODO: we should get _nonce_lock here (when WalletConnect is supported for swaps) let (nonce, _) = coin .clone() - .get_addr_nonce(my_address) + .get_addr_nonce(from_tagged.inner()) .compat() .timeout(ETH_RPC_REQUEST_TIMEOUT_S) .await? @@ -350,7 +551,7 @@ where gas, nonce, data: &data, - my_address, + my_address: from_tagged.inner(), action: Action::Call(call_addr), value: eth_value, gas_price, @@ -360,13 +561,11 @@ where }; let (tx, bytes) = if req.broadcast { - self.coin() - .wc_send_tx(&wc, params) + coin.wc_send_tx(&wc, params) .await .mm_err(|err| WithdrawError::SigningError(err.to_string()))? } else { - self.coin() - .wc_sign_tx(&wc, params) + coin.wc_sign_tx(&wc, params) .await .mm_err(|err| WithdrawError::SigningError(err.to_string()))? }; @@ -376,37 +575,10 @@ where }; self.on_finishing()?; - let tx_hash_bytes = BytesJson::from(tx_hash.0.to_vec()); - let tx_hash_str = format!("{tx_hash_bytes:02x}"); - - let amount_decimal = u256_to_big_decimal(wei_amount, coin.decimals).map_mm_err()?; - let mut spent_by_me = amount_decimal.clone(); - let received_by_me = if to_addr == my_address { - amount_decimal.clone() - } else { - 0.into() - }; let fee_details = EthTxFeeDetails::new(gas, pay_for_gas_option, fee_coin).map_mm_err()?; - if coin.coin_type == EthCoinType::Eth { - spent_by_me += &fee_details.total_fee; - } - Ok(TransactionDetails { - to: vec![checksum_address(&format!("{to_addr:#02x}"))], - from: vec![checksum_address(&format!("{my_address:#02x}"))], - total_amount: amount_decimal, - my_balance_change: &received_by_me - &spent_by_me, - spent_by_me, - received_by_me, - tx: TransactionData::new_signed(tx_hex, tx_hash_str), - block_height: 0, - fee_details: Some(fee_details.into()), - coin: coin.ticker.clone(), - internal_id: vec![].into(), - timestamp: now_sec(), - kmd_rewards: None, - transaction_type: Default::default(), - memo: None, - }) + let total_fee = fee_details.total_fee.clone(); + let tx = TransactionData::new_signed(tx_hex, format_tx_hash(tx_hash)); + self.build_transaction_details(&from_tagged, &to_tagged, tx, wei_amount, &total_fee, fee_details.into()) } } @@ -459,15 +631,11 @@ impl EthWithdraw for InitEthWithdraw { let sign_processor = TrezorRpcTaskProcessor::new(self.task_handle.clone(), trezor_statuses); let sign_processor = Arc::new(sign_processor); let mut trezor_session = hw_ctx.trezor(sign_processor).await.map_mm_err()?; - let chain_id = match coin.chain_spec { - ChainSpec::Evm { chain_id } => chain_id, - // Todo: Add support for Tron signing with Trezor - ChainSpec::Tron { .. } => { - return MmError::err(WithdrawError::InternalError( - "Tron is not supported for withdraw yet".to_owned(), - )) - }, - }; + // Todo: Add support for Tron signing with Trezor + let chain_id = coin + .chain_spec + .chain_id() + .ok_or_else(|| WithdrawError::InternalError("Tron is not supported for withdraw yet".to_owned()))?; let unverified_tx = trezor_session .sign_eth_tx(derivation_path, unsigned_tx, chain_id) .await @@ -541,7 +709,7 @@ impl StandardEthWithdraw { #[async_trait] impl GetWithdrawSenderAddress for EthCoin { - type Address = Address; + type Address = ChainTaggedAddress; type Pubkey = Public; async fn get_withdraw_sender_address( @@ -555,14 +723,12 @@ impl GetWithdrawSenderAddress for EthCoin { async fn eth_get_withdraw_from_address( coin: &EthCoin, req: &WithdrawRequest, -) -> MmResult, WithdrawError> { +) -> MmResult, WithdrawError> { match coin.derivation_method() { EthDerivationMethod::SingleAddress(my_address) => eth_get_withdraw_iguana_sender(coin, req, my_address), EthDerivationMethod::HDWallet(hd_wallet) => { let from = req.from.clone().or_mm_err(|| WithdrawError::FromAddressNotFound)?; - coin.get_withdraw_hd_sender(hd_wallet, &from) - .await - .mm_err(WithdrawError::from) + coin.get_withdraw_hd_sender(hd_wallet, &from).await.map_mm_err() }, } } @@ -571,8 +737,8 @@ async fn eth_get_withdraw_from_address( fn eth_get_withdraw_iguana_sender( coin: &EthCoin, req: &WithdrawRequest, - my_address: &Address, -) -> MmResult, WithdrawError> { + my_address: &ChainTaggedAddress, +) -> MmResult, WithdrawError> { if req.from.is_some() { let error = "'from' is not supported if the coin is initialized with an Iguana private key"; return MmError::err(WithdrawError::UnexpectedFromAddress(error.to_owned())); diff --git a/mm2src/coins/eth/for_tests.rs b/mm2src/coins/eth/for_tests.rs index 42337fe356..da902a2f23 100644 --- a/mm2src/coins/eth/for_tests.rs +++ b/mm2src/coins/eth/for_tests.rs @@ -1,5 +1,7 @@ #[cfg(not(target_arch = "wasm32"))] use super::*; +#[cfg(not(target_arch = "wasm32"))] +use crate::CoinsContext; use mm2_core::mm_ctx::{MmArc, MmCtxBuilder}; #[cfg(not(target_arch = "wasm32"))] use mm2_test_helpers::for_tests::{eth_sepolia_conf, ETH_SEPOLIA_SWAP_CONTRACT}; @@ -29,60 +31,42 @@ pub(crate) fn eth_coin_for_test( ) } +/// Helper to create an EthCoin for testing with the given parameters #[cfg(not(target_arch = "wasm32"))] -pub(crate) fn eth_coin_from_keypair( +fn make_eth_coin( + ctx: &MmArc, coin_type: EthCoinType, - urls: &[&str], - fallback_swap_contract: Option
, + ticker: String, key_pair: KeyPair, chain_id: u64, - coin_conf_json: Json, -) -> (MmArc, EthCoin) { - let mut web3_instances = vec![]; - for url in urls.iter() { - let node = HttpTransportNode { - uri: url.parse().unwrap(), - komodo_proxy: false, - }; - let transport = Web3Transport::new_http(node); - let web3 = Web3::new(transport); - web3_instances.push(Web3Instance(web3)); - } - drop_mutability!(web3_instances); - - let conf = json!({ "coins": [coin_conf_json] }); - let ctx = MmCtxBuilder::new().with_conf(conf).into_mm_arc(); - let ticker = match coin_type { - EthCoinType::Eth => "ETH".to_string(), - EthCoinType::Erc20 { .. } => "JST".to_string(), - EthCoinType::Nft { ref platform } => platform.to_string(), - }; - let platform_ticker = match coin_type { + fallback_swap_contract: Option
, + web3_instances: Vec, +) -> EthCoin { + let platform_ticker = match &coin_type { EthCoinType::Eth => "ETH".to_string(), - EthCoinType::Erc20 { ref platform, .. } => platform.to_string(), - EthCoinType::Nft { ref platform } => platform.to_string(), + EthCoinType::Erc20 { platform, .. } | EthCoinType::Nft { platform } => platform.to_string(), }; - let my_address = key_pair.address(); - let coin_conf = coin_conf(&ctx, &ticker); - let max_eth_tx_type = get_conf_param_or_from_plaform_coin(&ctx, &coin_conf, &coin_type, MAX_ETH_TX_TYPE_SUPPORTED) + let my_address = ChainTaggedAddress::new(key_pair.address(), ChainFamily::Evm); + let coin_conf = coin_conf(ctx, &ticker); + let max_eth_tx_type = get_conf_param_or_from_plaform_coin(ctx, &coin_conf, &coin_type, MAX_ETH_TX_TYPE_SUPPORTED) .expect("valid max_eth_tx_type config"); - let gas_price_adjust = get_conf_param_or_from_plaform_coin(&ctx, &coin_conf, &coin_type, GAS_PRICE_ADJUST) + let gas_price_adjust = get_conf_param_or_from_plaform_coin(ctx, &coin_conf, &coin_type, GAS_PRICE_ADJUST) .expect("expected valid gas adjust config"); - let estimate_gas_mult = get_conf_param_or_from_plaform_coin(&ctx, &coin_conf, &coin_type, ESTIMATE_GAS_MULT) + let estimate_gas_mult = get_conf_param_or_from_plaform_coin(ctx, &coin_conf, &coin_type, ESTIMATE_GAS_MULT) .expect("expected valid estimate gas mult config"); - let gas_limit: EthGasLimit = get_conf_param_or_from_plaform_coin(&ctx, &coin_conf, &coin_type, EthGasLimit::key()) + let gas_limit: EthGasLimit = get_conf_param_or_from_plaform_coin(ctx, &coin_conf, &coin_type, EthGasLimit::key()) .expect("expected valid gas_limit config") .unwrap_or_default(); let gas_limit_v2: EthGasLimitV2 = - get_conf_param_or_from_plaform_coin(&ctx, &coin_conf, &coin_type, EthGasLimitV2::key()) + get_conf_param_or_from_plaform_coin(ctx, &coin_conf, &coin_type, EthGasLimitV2::key()) .expect("expected valid gas_limit config") .unwrap_or_default(); let swap_gas_fee_policy: SwapGasFeePolicy = - get_conf_param_or_from_plaform_coin(&ctx, &coin_conf, &coin_type, SWAP_GAS_FEE_POLICY) + get_conf_param_or_from_plaform_coin(ctx, &coin_conf, &coin_type, SWAP_GAS_FEE_POLICY) .expect("valid swap_gas_fee_policy config") .unwrap_or_default(); - let eth_coin = EthCoin(Arc::new(EthCoinImpl { + EthCoin(Arc::new(EthCoinImpl { coin_type, chain_spec: ChainSpec::Evm { chain_id }, decimals: 18, @@ -96,6 +80,7 @@ pub(crate) fn eth_coin_from_keypair( contract_supports_watchers: false, ticker, web3_instances: AsyncMutex::new(web3_instances), + rpc_client: None, ctx: ctx.weak(), required_confirmations: 1.into(), swap_gas_fee_policy: Mutex::new(swap_gas_fee_policy), @@ -110,6 +95,61 @@ pub(crate) fn eth_coin_from_keypair( gas_limit_v2, estimate_gas_mult, abortable_system: AbortableQueue::default(), - })); + })) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn eth_coin_from_keypair( + coin_type: EthCoinType, + urls: &[&str], + fallback_swap_contract: Option
, + key_pair: KeyPair, + chain_id: u64, + coin_conf_json: Json, +) -> (MmArc, EthCoin) { + let conf = json!({ "coins": [coin_conf_json] }); + let ctx = MmCtxBuilder::new().with_conf(conf).into_mm_arc(); + + // For ERC20/NFT tokens, register the platform coin first so platform_coin() lookups work + if let EthCoinType::Erc20 { ref platform, .. } | EthCoinType::Nft { ref platform } = coin_type { + let platform_coin = make_eth_coin( + &ctx, + EthCoinType::Eth, + platform.clone(), + key_pair.clone(), + chain_id, + fallback_swap_contract, + vec![], + ); + let coins_ctx = CoinsContext::from_ctx(&ctx).unwrap(); + common::block_on(coins_ctx.add_platform_with_tokens(platform_coin.into(), vec![], None)).unwrap(); + } + + let web3_instances: Vec<_> = urls + .iter() + .map(|url| { + let node = HttpTransportNode { + uri: url.parse().unwrap(), + komodo_proxy: false, + }; + Web3Instance(Web3::new(Web3Transport::new_http(node))) + }) + .collect(); + + let ticker = match &coin_type { + EthCoinType::Erc20 { .. } => "JST".to_string(), + EthCoinType::Nft { platform } => platform.to_string(), + _ => coin_type.to_string(), + }; + + let eth_coin = make_eth_coin( + &ctx, + coin_type, + ticker, + key_pair, + chain_id, + fallback_swap_contract, + web3_instances, + ); (ctx, eth_coin) } diff --git a/mm2src/coins/eth/nft_swap_v2/mod.rs b/mm2src/coins/eth/nft_swap_v2/mod.rs index a55c66a336..1402574959 100644 --- a/mm2src/coins/eth/nft_swap_v2/mod.rs +++ b/mm2src/coins/eth/nft_swap_v2/mod.rs @@ -53,7 +53,8 @@ impl EthCoin { .await }, EthCoinType::Eth | EthCoinType::Erc20 { .. } => Err(TransactionErr::ProtocolNotSupported(ERRL!( - "ETH and ERC20 protocols are not supported for NFT swaps." + "{} protocol is not supported for NFT swaps.", + self.coin_type ))), } } @@ -94,7 +95,9 @@ impl EthCoin { })?; let signed_tx = signed_tx_from_web3_tx(tx_from_rpc.clone()) .map_err(|err| ValidatePaymentError::WrongPaymentTx(format!("Could not parse tx: {:?}", err)))?; - validate_from_to_addresses(&signed_tx, maker_address, *token_address).map_mm_err()?; + let maker_address_tagged = self.tag_address(maker_address); + let token_address_tagged = self.tag_address(*token_address); + validate_from_to_addresses(&signed_tx, maker_address_tagged, token_address_tagged).map_mm_err()?; let (decoded, bytes_index) = get_decoded_tx_data_and_bytes_index(contract_type, &tx_from_rpc.input.0)?; @@ -167,7 +170,8 @@ impl EthCoin { .await }, EthCoinType::Eth | EthCoinType::Erc20 { .. } => Err(TransactionErr::ProtocolNotSupported(ERRL!( - "ETH and ERC20 protocols are not supported for NFT swaps." + "{} protocol is not supported for NFT swaps.", + self.coin_type ))), } } @@ -204,7 +208,8 @@ impl EthCoin { .await }, EthCoinType::Eth | EthCoinType::Erc20 { .. } => Err(TransactionErr::ProtocolNotSupported(ERRL!( - "ETH and ERC20 protocols are not supported for NFT swaps." + "{} protocol is not supported for NFT swaps.", + self.coin_type ))), } } @@ -242,7 +247,8 @@ impl EthCoin { .await }, EthCoinType::Eth | EthCoinType::Erc20 { .. } => Err(TransactionErr::ProtocolNotSupported(ERRL!( - "ETH and ERC20 protocols are not supported for NFT swaps." + "{} protocol is not supported for NFT swaps.", + self.coin_type ))), } } diff --git a/mm2src/coins/eth/swap_contract_abi.json b/mm2src/coins/eth/swap_contract_abi.json index 79cd76e233..28e1cf8ada 100644 --- a/mm2src/coins/eth/swap_contract_abi.json +++ b/mm2src/coins/eth/swap_contract_abi.json @@ -1,415 +1,224 @@ [ - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "bytes32", - "name": "id", - "type": "bytes32" - } - ], - "name": "PaymentSent", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "bytes32", - "name": "id", - "type": "bytes32" - }, - { - "indexed": false, - "internalType": "bytes32", - "name": "secret", - "type": "bytes32" - } - ], - "name": "ReceiverSpent", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "bytes32", - "name": "id", - "type": "bytes32" - } - ], - "name": "SenderRefunded", - "type": "event" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "_id", - "type": "bytes32" - }, - { - "internalType": "uint256", - "name": "_amount", - "type": "uint256" - }, - { - "internalType": "address", - "name": "_tokenAddress", - "type": "address" - }, - { - "internalType": "address", - "name": "_receiver", - "type": "address" - }, - { - "internalType": "bytes20", - "name": "_secretHash", - "type": "bytes20" - }, - { - "internalType": "uint64", - "name": "_lockTime", - "type": "uint64" - } - ], - "name": "erc20Payment", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "_id", - "type": "bytes32" - }, - { - "internalType": "uint256", - "name": "_amount", - "type": "uint256" - }, - { - "internalType": "address", - "name": "_tokenAddress", - "type": "address" - }, - { - "internalType": "address", - "name": "_receiver", - "type": "address" - }, - { - "internalType": "bytes20", - "name": "_secretHash", - "type": "bytes20" - }, - { - "internalType": "uint64", - "name": "_lockTime", - "type": "uint64" - }, - { - "internalType": "enum EtomicSwap.RewardTargetOnSpend", - "name": "_rewardTarget", - "type": "uint8" - }, - { - "internalType": "bool", - "name": "_sendsContractRewardOnSpend", - "type": "bool" - }, - { - "internalType": "uint256", - "name": "_rewardAmount", - "type": "uint256" - } - ], - "name": "erc20PaymentReward", - "outputs": [], - "stateMutability": "payable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "_id", - "type": "bytes32" - }, - { - "internalType": "address", - "name": "_receiver", - "type": "address" - }, - { - "internalType": "bytes20", - "name": "_secretHash", - "type": "bytes20" - }, - { - "internalType": "uint64", - "name": "_lockTime", - "type": "uint64" - } - ], - "name": "ethPayment", - "outputs": [], - "stateMutability": "payable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "_id", - "type": "bytes32" - }, - { - "internalType": "address", - "name": "_receiver", - "type": "address" - }, - { - "internalType": "bytes20", - "name": "_secretHash", - "type": "bytes20" - }, - { - "internalType": "uint64", - "name": "_lockTime", - "type": "uint64" - }, - { - "internalType": "enum EtomicSwap.RewardTargetOnSpend", - "name": "_rewardTarget", - "type": "uint8" - }, - { - "internalType": "bool", - "name": "_sendsContractRewardOnSpend", - "type": "bool" - }, - { - "internalType": "uint256", - "name": "_rewardAmount", - "type": "uint256" - } - ], - "name": "ethPaymentReward", - "outputs": [], - "stateMutability": "payable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "_id", - "type": "bytes32" - }, - { - "internalType": "uint256", - "name": "_amount", - "type": "uint256" - }, - { - "internalType": "bytes32", - "name": "_secret", - "type": "bytes32" - }, - { - "internalType": "address", - "name": "_tokenAddress", - "type": "address" - }, - { - "internalType": "address", - "name": "_sender", - "type": "address" - } - ], - "name": "receiverSpend", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "_id", - "type": "bytes32" - }, - { - "internalType": "uint256", - "name": "_amount", - "type": "uint256" - }, - { - "internalType": "bytes32", - "name": "_secret", - "type": "bytes32" - }, - { - "internalType": "address", - "name": "_tokenAddress", - "type": "address" - }, - { - "internalType": "address", - "name": "_sender", - "type": "address" - }, - { - "internalType": "address", - "name": "_receiver", - "type": "address" - }, - { - "internalType": "enum EtomicSwap.RewardTargetOnSpend", - "name": "_rewardTarget", - "type": "uint8" - }, - { - "internalType": "bool", - "name": "_sendsContractRewardOnSpend", - "type": "bool" - }, - { - "internalType": "uint256", - "name": "_rewardAmount", - "type": "uint256" - } - ], - "name": "receiverSpendReward", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "_id", - "type": "bytes32" - }, - { - "internalType": "uint256", - "name": "_amount", - "type": "uint256" - }, - { - "internalType": "bytes20", - "name": "_paymentHash", - "type": "bytes20" - }, - { - "internalType": "address", - "name": "_tokenAddress", - "type": "address" - }, - { - "internalType": "address", - "name": "_receiver", - "type": "address" - } - ], - "name": "senderRefund", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "_id", - "type": "bytes32" - }, - { - "internalType": "uint256", - "name": "_amount", - "type": "uint256" - }, - { - "internalType": "bytes20", - "name": "_paymentHash", - "type": "bytes20" - }, - { - "internalType": "address", - "name": "_tokenAddress", - "type": "address" - }, - { - "internalType": "address", - "name": "_sender", - "type": "address" - }, - { - "internalType": "address", - "name": "_receiver", - "type": "address" - }, - { - "internalType": "enum EtomicSwap.RewardTargetOnSpend", - "name": "_rewardTarget", - "type": "uint8" - }, - { - "internalType": "bool", - "name": "_sendsReward", - "type": "bool" - }, - { - "internalType": "uint256", - "name": "_rewardAmount", - "type": "uint256" - } - ], - "name": "senderRefundReward", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "stateMutability": "nonpayable", - "type": "constructor" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "", - "type": "bytes32" - } - ], - "name": "payments", - "outputs": [ - { - "internalType": "bytes20", - "name": "paymentHash", - "type": "bytes20" - }, - { - "internalType": "uint64", - "name": "lockTime", - "type": "uint64" - }, - { - "internalType": "enum EtomicSwap.PaymentState", - "name": "state", - "type": "uint8" - } - ], - "stateMutability": "view", - "type": "function" - } -] \ No newline at end of file + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "SafeERC20FailedOperation", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "bytes32", + "name": "id", + "type": "bytes32" + } + ], + "name": "PaymentSent", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "bytes32", + "name": "id", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "secret", + "type": "bytes32" + } + ], + "name": "ReceiverSpent", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "bytes32", + "name": "id", + "type": "bytes32" + } + ], + "name": "SenderRefunded", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "id", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "address", + "name": "tokenAddress", + "type": "address" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "bytes20", + "name": "secretHash", + "type": "bytes20" + }, + { + "internalType": "uint64", + "name": "lockTime", + "type": "uint64" + } + ], + "name": "erc20Payment", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "id", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "bytes20", + "name": "secretHash", + "type": "bytes20" + }, + { + "internalType": "uint64", + "name": "lockTime", + "type": "uint64" + } + ], + "name": "ethPayment", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "payments", + "outputs": [ + { + "internalType": "bytes20", + "name": "paymentHash", + "type": "bytes20" + }, + { + "internalType": "uint64", + "name": "lockTime", + "type": "uint64" + }, + { + "internalType": "enum EtomicSwap.PaymentState", + "name": "state", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "id", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "secret", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "tokenAddress", + "type": "address" + }, + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "receiverSpend", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "id", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes20", + "name": "secretHash", + "type": "bytes20" + }, + { + "internalType": "address", + "name": "tokenAddress", + "type": "address" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "senderRefund", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/mm2src/coins/eth/tron.rs b/mm2src/coins/eth/tron.rs index c07307c13d..1ad22fc78f 100644 --- a/mm2src/coins/eth/tron.rs +++ b/mm2src/coins/eth/tron.rs @@ -1,8 +1,37 @@ -//! Minimal Tron placeholders for EthCoin integration. -//! These types will be expanded with full TRON logic in later steps. +//! TRON blockchain support for EthCoin integration. +//! +//! TRON uses a 21-byte address format (0x41 prefix + 20 bytes) displayed as Base58Check. +//! Native currency is TRX with 6 decimals (1 TRX = 1,000,000 SUN). mod address; +pub mod api; +pub mod fee; +pub(crate) mod proto; +pub(crate) mod sign; +pub mod tx_builder; +pub mod withdraw; + +/// Integration tests using real TRON testnet (Nile). +/// These tests require network access and are gated behind the `tron-network-tests` feature. +/// Run with: `cargo test -p coins --features tron-network-tests --lib tron_nile` +#[cfg(all(test, feature = "tron-network-tests"))] +mod api_integration_tests; + pub use address::Address as TronAddress; +pub use api::{BroadcastHexResponse, TaposBlockData, TronApiClient, TronHttpClient, TronHttpNode}; + +use ethabi::Token; +use ethereum_types::U256; +use serde::{Deserialize, Serialize}; + +pub const TRX_DECIMALS: u8 = 6; + +/// Build ABI tokens for a TRC20 `transfer(address,uint256)` call. +/// +/// Shared by `tx_builder` (full ABI encoding with selector) and `api` (parameter-only encoding). +pub(crate) fn trc20_transfer_tokens(recipient: &TronAddress, amount: U256) -> [Token; 2] { + [Token::Address(recipient.to_evm_address()), Token::Uint(amount)] +} /// Represents TRON chain/network. #[derive(Clone, Debug, Deserialize, Serialize)] @@ -10,15 +39,101 @@ pub enum Network { Mainnet, Shasta, Nile, - // TODO: Add more networks as needed. } -/// Placeholder for a TRON client. -#[derive(Clone, Debug)] -pub struct TronClient; +/// Hard cap on TRON raw transaction size to prevent oversized-input DoS. +/// Typical TRON transactions are a few hundred bytes; 256 KiB is generous. +pub const MAX_TRON_RAW_TX_BYTES: usize = 256 * 1024; -/// Placeholder for TRON fee params. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TronFeeParams { - // TODO: Add TRON-specific fields in future steps. +/// Strips optional `0x`/`0X` prefix and validates the hex string for TRON broadcast. +/// +/// Checks: non-empty, bounded length, even character count, ASCII hex digits only. +pub fn normalize_tron_raw_tx_hex(input: &str) -> Result { + let s = input + .strip_prefix("0x") + .or_else(|| input.strip_prefix("0X")) + .unwrap_or(input); + + if s.is_empty() { + return Err("TRON raw transaction hex is empty".to_owned()); + } + if s.len() > MAX_TRON_RAW_TX_BYTES * 2 { + return Err(format!( + "TRON raw transaction hex too large: {} chars (max {})", + s.len(), + MAX_TRON_RAW_TX_BYTES * 2, + )); + } + if !s.len().is_multiple_of(2) { + return Err("TRON raw transaction hex has odd length".to_owned()); + } + if !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("TRON raw transaction hex contains non-hex characters".to_owned()); + } + Ok(s.to_owned()) +} + +/// Validates that TRON raw transaction bytes are non-empty and within the size limit. +pub fn validate_tron_raw_tx_len(len: usize) -> Result<(), String> { + if len == 0 { + return Err("TRON raw transaction bytes are empty".to_owned()); + } + if len > MAX_TRON_RAW_TX_BYTES { + return Err(format!( + "TRON raw transaction too large: {} bytes (max {})", + len, MAX_TRON_RAW_TX_BYTES, + )); + } + Ok(()) +} + +/// Shared test fixtures for TRON unit tests. +#[cfg(test)] +pub(super) mod test_fixtures { + use super::api::TaposBlockData; + + pub const TEST_FROM_HEX: &str = "4123b00d15c601b30613bf5a3b2f72527c79cc08b6"; + pub const TEST_TO_HEX: &str = "418840e6c55b9ada326d211d818c34a994aeced808"; + + /// Nile testnet block 64,687,673 β€” used as TAPOS source in golden vector tests. + pub fn nile_block_64687673() -> TaposBlockData { + TaposBlockData { + number: 64_687_673, + block_id: { + let bytes = hex::decode("0000000003db0e39901ce5715271b601b1c57055f5d8fa6a9fe3505eee560308").unwrap(); + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + arr + }, + timestamp: 1_770_522_369_000, + } + } +} + +#[cfg(test)] +mod raw_tx_validation_tests { + use super::*; + + #[test] + fn normalize_tron_raw_tx_hex_validates_input() { + // Valid inputs: strips 0x/0X prefix, accepts bare hex + assert_eq!(normalize_tron_raw_tx_hex("0xabcd").unwrap(), "abcd"); + assert_eq!(normalize_tron_raw_tx_hex("0Xabcd").unwrap(), "abcd"); + assert_eq!(normalize_tron_raw_tx_hex("abcd1234").unwrap(), "abcd1234"); + + // Rejections: empty, prefix-only, odd length, non-hex, oversized + assert!(normalize_tron_raw_tx_hex("").is_err()); + assert!(normalize_tron_raw_tx_hex("0x").is_err()); + assert!(normalize_tron_raw_tx_hex("abc").is_err()); + assert!(normalize_tron_raw_tx_hex("abcg").is_err()); + let oversized = "ab".repeat(MAX_TRON_RAW_TX_BYTES + 1); + assert!(normalize_tron_raw_tx_hex(&oversized).is_err()); + } + + #[test] + fn validate_tron_raw_tx_len_validates_bounds() { + assert!(validate_tron_raw_tx_len(0).is_err()); + assert!(validate_tron_raw_tx_len(1000).is_ok()); + assert!(validate_tron_raw_tx_len(MAX_TRON_RAW_TX_BYTES + 1).is_err()); + } } diff --git a/mm2src/coins/eth/tron/address.rs b/mm2src/coins/eth/tron/address.rs index 0d2931ed10..1e1c5f882c 100644 --- a/mm2src/coins/eth/tron/address.rs +++ b/mm2src/coins/eth/tron/address.rs @@ -1,5 +1,6 @@ //! TRON address handling (base58, hex, validation, serde). +use ethereum_types::Address as EthAddress; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::convert::{TryFrom, TryInto}; use std::fmt; @@ -14,7 +15,7 @@ pub const ADDRESS_BASE58_LEN: usize = 34; /// TRON mainnet or testnet address (21 bytes, 0x41 prefix + 20-bytes). #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Address { - pub inner: [u8; ADDRESS_BYTES_LEN], + inner: [u8; ADDRESS_BYTES_LEN], } impl Address { @@ -84,6 +85,26 @@ impl Address { pub fn as_bytes(&self) -> &[u8] { &self.inner } + + /// Extracts the 20-byte EVM address from this TRON address. + /// + /// TRON addresses are 21 bytes: a 0x41 prefix followed by a 20-byte EVM address. + /// This method returns the 20-byte portion as an `ethereum_types::Address`. + /// + /// # Safety + /// This is safe because `self.inner` is a fixed-size `[u8; 21]` array, + /// guaranteed at compile-time, so slicing `[1..21]` cannot panic. + pub fn to_evm_address(&self) -> EthAddress { + EthAddress::from_slice(&self.inner[1..21]) + } + + /// Construct TRON address from raw 20-byte Ethereum address bytes + fn from_eth_bytes(bytes: &[u8; 20]) -> Self { + let mut inner = [0u8; ADDRESS_BYTES_LEN]; + inner[0] = ADDRESS_PREFIX; + inner[1..].copy_from_slice(bytes); + Self { inner } + } } impl TryFrom<[u8; ADDRESS_BYTES_LEN]> for Address { @@ -153,6 +174,18 @@ impl FromStr for Address { } } +impl From for Address { + fn from(eth_addr: EthAddress) -> Self { + Address::from_eth_bytes(eth_addr.as_fixed_bytes()) + } +} + +impl From<&EthAddress> for Address { + fn from(eth_addr: &EthAddress) -> Self { + Address::from_eth_bytes(eth_addr.as_fixed_bytes()) + } +} + #[cfg(test)] mod test { use super::*; @@ -166,6 +199,11 @@ mod test { assert_eq!(addr1, addr2); assert_eq!(addr1.to_hex(), hex); assert_eq!(addr2.to_base58(), base58); + + // Test with 0x prefix + let hex_0x = "0x418840e6c55b9ada326d211d818c34a994aeced808"; + let addr3 = Address::from_str(hex_0x).unwrap(); + assert_eq!(addr3, addr1); } #[test] @@ -173,4 +211,21 @@ mod test { assert!(Address::from_str("foo").is_err()); assert!(Address::from_str("0xdeadbeef").is_err()); } + + #[test] + fn test_convert_eth_address_to_tron() { + use ethereum_types::Address as EthAddress; + + let eth_hex = "8840e6c55b9ada326d211d818c34a994aeced808"; + let eth_bytes = hex::decode(eth_hex).unwrap(); + let eth_address = EthAddress::from_slice(ð_bytes); + + let tron_address = Address::from(eth_address); + + let expected_hex = format!("41{}", eth_hex); + assert_eq!(tron_address.to_hex(), expected_hex); + + let expected_base58 = "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL"; + assert_eq!(tron_address.to_base58(), expected_base58); + } } diff --git a/mm2src/coins/eth/tron/api.rs b/mm2src/coins/eth/tron/api.rs new file mode 100644 index 0000000000..b399180e3d --- /dev/null +++ b/mm2src/coins/eth/tron/api.rs @@ -0,0 +1,1294 @@ +//! TRON HTTP API client for wallet operations. +//! +//! Implements the minimal TRON API endpoints needed for HD activation and balance queries: +//! - `/wallet/getnowblock` - current block number +//! - `/wallet/getaccount` - account info (balance, existence) +//! +//! # TODO: RPC Pool Trait Refactoring +//! +//! The current structure has node rotation logic duplicated between EVM (`try_rpc_send` in +//! `eth_rpc.rs`) and TRON (`try_clients` here). This should be unified via a common trait: +//! +//! ```ignore +//! #[async_trait] +//! pub trait RpcPool: Send + Sync + Clone { +//! type Client: Send + Sync + Clone; +//! type Error; +//! +//! async fn try_nodes(&self, op: F) -> Result +//! where +//! F: Fn(Self::Client) -> Fut + Send + Sync, +//! Fut: Future> + Send; +//! +//! fn is_retryable(error: &Self::Error) -> bool; +//! } +//! ``` +//! +//! See `docs/plans/chain-rpc-client-refactor.md` for the full refactoring plan. + +use super::fee::{TronAccountResources, TronChainPrices}; +use super::{trc20_transfer_tokens, TronAddress}; +use crate::eth::{Web3RpcError, Web3RpcResult}; + +use common::{APPLICATION_JSON, PROXY_REQUEST_EXPIRATION_SEC, X_AUTH_PAYLOAD}; +use ethereum_types::U256; +use http::header::CONTENT_TYPE; +use http::Uri; +use mm2_p2p::Keypair; +use proxy_signature::RawMessage; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value as Json; +use serde_json::{self as json}; +use std::convert::TryInto; +use std::sync::Arc; +use std::time::Duration; + +/// Timeout for individual TRON API requests. +pub const TRON_API_TIMEOUT: Duration = Duration::from_secs(10); + +// ============================================================================ +// TRON Error Classification +// ============================================================================ + +/// Structured TRON API error payload with code and message. +/// +/// Separating code and message enables proper retry classification at the source. +#[derive(Debug)] +struct TronErrorPayload { + code: Option, + message: String, +} + +impl TronErrorPayload { + /// Check if this error indicates a transient condition that should be retried. + /// + /// Based on TRON's `Return.response_code` enum: + /// + /// + /// Transient codes (retryable): + /// - `SERVER_BUSY` (code 9) - node's transaction pending pool is at capacity + /// - `NO_CONNECTION` (code 10) - no active peer connections + /// - `NOT_ENOUGH_EFFECTIVE_CONNECTION` (code 11) - insufficient peer connections + /// - `BLOCK_UNSOLIDIFIED` (code 12) - blockchain not fully synchronized + /// - Rate limiting: "lack of computing resources" message + /// + /// All other codes are permanent (not retryable): SIGERROR, CONTRACT_VALIDATE_ERROR, + /// CONTRACT_EXE_ERROR, BANDWITH_ERROR, DUP_TRANSACTION_ERROR, TAPOS_ERROR, + /// TOO_BIG_TRANSACTION_ERROR, TRANSACTION_EXPIRATION_ERROR, OTHER_ERROR. + /// + /// # Why string codes instead of numeric codes + /// + /// TRON's HTTP API serializes enum values as string names via `JsonFormat.printToString`. + /// Example response: `{"code": "SERVER_BUSY", "message": "..."}`. + /// See: + fn is_retryable(&self) -> bool { + const RETRYABLE_CODES: &[&str] = &[ + "SERVER_BUSY", + "NO_CONNECTION", + "NOT_ENOUGH_EFFECTIVE_CONNECTION", + "BLOCK_UNSOLIDIFIED", + ]; + + // Rate limiting message from RateLimiterServlet (not a response_code, but a servlet error). + // See: https://github.com/tronprotocol/java-tron/blob/1e35f79/framework/src/main/java/org/tron/core/services/http/RateLimiterServlet.java#L114 + const RATE_LIMIT_MSG: &str = "lack of computing resources"; + + if let Some(c) = &self.code { + if RETRYABLE_CODES.contains(&c.as_str()) { + return true; + } + } + + self.message.contains(RATE_LIMIT_MSG) + } +} + +impl std::fmt::Display for TronErrorPayload { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.code { + Some(code) => write!(f, "{}: {}", code, self.message), + None => write!(f, "{}", self.message), + } + } +} + +/// Detects TRON API error payloads and extracts structured error information. +/// Returns `Some(TronErrorPayload)` if the response indicates an error, `None` otherwise. +/// +/// TRON API error formats (HTTP API): +/// - `{"Error": "message"}` - Generic servlet errors +/// +/// - `{"error": {"code": ..., "message": ...}}` - JSON-RPC 2.0 errors (for future use) +/// - `{"result": false, "code": "...", "message": "..."}` - Return message (broadcasttransaction) +/// +/// - `{"result": {"result": false, "code": "...", "message": "..."}}` - Nested Return (triggersmartcontract, estimateenergy) +/// +/// +/// Non-error: `{}` for non-existent accounts () +fn tron_error_from_value(v: &Json) -> Option { + let obj = v.as_object()?; + + // Helper to convert JSON values to string (handles string, number, or fallback to JSON repr) + let value_to_string = |v: &Json| -> String { + match v { + Json::String(s) => s.clone(), + Json::Number(n) => n.to_string(), + other => other.to_string(), + } + }; + + // Format: {"Error": "message"} - Generic servlet errors (Util.printErrorMsg, JsonFormat.printErrorMsg) + if let Some(msg) = obj.get("Error").and_then(|v| v.as_str()) { + return Some(TronErrorPayload { + code: None, + message: msg.to_string(), + }); + } + + // Format: {"error": {"code": ..., "message": ...}} - JSON-RPC 2.0 errors (for future use) + if let Some(error_obj) = obj.get("error").and_then(|v| v.as_object()) { + let code = error_obj.get("code").map(&value_to_string); + let message = error_obj + .get("message") + .map(&value_to_string) + .unwrap_or_else(|| "JSON-RPC error".to_string()); + return Some(TronErrorPayload { code, message }); + } + + // Format: {"result": {"result": false, "code": "...", "message": "..."}} - Nested Return + // Used by: TransactionExtention (triggersmartcontract), EstimateEnergyMessage (estimateenergy) + // Note: "result" can be false, null, or missing when there's an error + if let Some(result_obj) = obj.get("result").and_then(|v| v.as_object()) { + let inner_result = result_obj.get("result").and_then(|v| v.as_bool()); + let has_error_code = result_obj.get("code").is_some(); + + // Error if: inner result is false, OR inner result is null/missing but has error code + if inner_result == Some(false) || (inner_result.is_none() && has_error_code) { + let code = result_obj.get("code").map(&value_to_string); + let message = result_obj + .get("message") + .map(&value_to_string) + .unwrap_or_else(|| "Transaction failed".to_string()); + return Some(TronErrorPayload { code, message }); + } + } + + // Format: {"result": false, "code": "...", "message": "..."} - Top-level Return (broadcasttransaction) + if matches!(obj.get("result").and_then(|v| v.as_bool()), Some(false)) { + let code = obj.get("code").map(&value_to_string); + let message = obj + .get("message") + .map(&value_to_string) + .unwrap_or_else(|| "TRON API returned result=false".to_string()); + return Some(TronErrorPayload { code, message }); + } + + None +} + +/// TRON HTTP transport node configuration. +#[derive(Clone, Debug)] +pub struct TronHttpNode { + pub uri: Uri, + pub komodo_proxy: bool, +} + +/// TRON HTTP client for a single node. +#[derive(Clone, Debug)] +pub struct TronHttpClient { + pub node: TronHttpNode, + /// Keypair for signing requests to komodo proxy nodes. + proxy_sign_keypair: Option>, +} + +impl TronHttpClient { + pub fn new(node: TronHttpNode, proxy_sign_keypair: Option>) -> Self { + Self { + node, + proxy_sign_keypair, + } + } + + /// Builds the proxy signature JSON string for komodo proxy nodes. + /// Returns `None` when this node is not a proxy. + fn proxy_sign_json(&self, uri: &Uri, body_len: usize) -> Web3RpcResult> { + if !self.node.komodo_proxy { + return Ok(None); + } + let keypair = self.proxy_sign_keypair.as_ref().ok_or_else(|| { + Web3RpcError::Internal("Proxy node requires signing keypair but none provided".to_string()) + })?; + let proxy_sign = RawMessage::sign(keypair, uri, body_len, PROXY_REQUEST_EXPIRATION_SEC) + .map_err(|e| Web3RpcError::Internal(format!("Proxy signing failed: {e}")))?; + let json_str = json::to_string(&proxy_sign).map_err(|e| Web3RpcError::Internal(e.to_string()))?; + Ok(Some(json_str)) + } + + /// Send a POST request to the TRON API. + /// + /// Error classification at source: + /// - **Retryable**: malformed JSON, unexpected payload structure, transient TRON errors + /// (SERVER_BUSY, NO_CONNECTION, etc.), rate limiting. These trigger node rotation. + /// - **Non-retryable**: permanent TRON errors like CONTRACT_VALIDATE_ERROR, SIGERROR, etc. + /// These would fail on any node. + /// - **Internal**: programming errors (invalid URI, serialization bugs). Not retryable. + pub async fn post(&self, path: &str, body: &T) -> Web3RpcResult { + // Build URI, avoiding double slashes + let base = self.node.uri.to_string(); + let base = base.trim_end_matches('/'); + let path = path.trim_start_matches('/'); + let uri_str = format!("{}/{}", base, path); + let uri: Uri = uri_str + .parse() + .map_err(|e| Web3RpcError::Internal(format!("Invalid URI: {e}")))?; + + let body_bytes = json::to_vec(body).map_err(|e| Web3RpcError::Internal(e.to_string()))?; + let response_bytes = self.send_request(&uri, body_bytes).await?; + + // Parse JSON once. Malformed JSON = faulty node, try another. + let response_json: Json = json::from_slice(&response_bytes) + .map_err(|e| Web3RpcError::BadResponse(format!("TRON node returned malformed JSON: {e}")))?; + + // Check for TRON error payloads (200 OK but error content). + // Classify transient errors as retryable; permanent rejections as non-retryable. + if let Some(tron_err) = tron_error_from_value(&response_json) { + if tron_err.is_retryable() { + return Err(Web3RpcError::Transport(format!("TRON API transient error: {tron_err}")).into()); + } else { + return Err(Web3RpcError::RemoteError { + code: tron_err.code, + message: tron_err.message, + } + .into()); + } + } + + // Convert Json to typed response. Unexpected structure = faulty node, try another. + json::from_value(response_json) + .map_err(|e| Web3RpcError::BadResponse(format!("TRON node returned unexpected payload: {e}")).into()) + } + + #[cfg(not(target_arch = "wasm32"))] + async fn send_request(&self, uri: &Uri, body: Vec) -> Web3RpcResult> { + use common::custom_futures::timeout::FutureTimerExt; + use http::header::HeaderValue; + use mm2_net::transport::slurp_req; + + let mut req = http::Request::new(body.clone()); + *req.method_mut() = http::Method::POST; + *req.uri_mut() = uri.clone(); + req.headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static(APPLICATION_JSON)); + + if let Some(proxy_json) = self.proxy_sign_json(uri, body.len())? { + let header_value = proxy_json + .parse() + .map_err(|e| Web3RpcError::Internal(format!("Invalid proxy header value: {e}")))?; + req.headers_mut().insert(X_AUTH_PAYLOAD, header_value); + } + + match Box::pin(slurp_req(req)).timeout(TRON_API_TIMEOUT).await { + Ok(Ok((status, _headers, response_body))) => { + if !status.is_success() { + return Err(Web3RpcError::Transport(format!( + "TRON API returned status {}: {}", + status, + String::from_utf8_lossy(&response_body) + )) + .into()); + } + Ok(response_body) + }, + Ok(Err(e)) => Err(Web3RpcError::Transport(format!("Request failed: {e}")).into()), + Err(_timeout) => Err(Web3RpcError::Timeout(format!("Request to {} timed out", uri)).into()), + } + } + + #[cfg(target_arch = "wasm32")] + async fn send_request(&self, uri: &Uri, body: Vec) -> Web3RpcResult> { + use common::custom_futures::timeout::FutureTimerExt; + use http::header::ACCEPT; + use mm2_net::wasm::http::FetchRequest; + + let body_str = + String::from_utf8(body.clone()).map_err(|e| Web3RpcError::Internal(format!("Invalid UTF-8 body: {e}")))?; + + let mut request = FetchRequest::post(&uri.to_string()); + request = request + .cors() + .body_utf8(body_str) + .header(ACCEPT.as_str(), APPLICATION_JSON) + .header(CONTENT_TYPE.as_str(), APPLICATION_JSON); + + if let Some(proxy_json) = self.proxy_sign_json(uri, body.len())? { + request = request.header(X_AUTH_PAYLOAD, &proxy_json); + } + + let (status_code, response_str) = match Box::pin(request.request_str()).timeout(TRON_API_TIMEOUT).await { + Ok(Ok(result)) => result, + Ok(Err(e)) => return Err(Web3RpcError::Transport(format!("WASM fetch failed: {e:?}")).into()), + Err(_timeout) => return Err(Web3RpcError::Timeout(format!("Request to {} timed out", uri)).into()), + }; + + if !status_code.is_success() { + return Err( + Web3RpcError::Transport(format!("TRON API returned status {}: {}", status_code, response_str)).into(), + ); + } + + Ok(response_str.into_bytes()) + } +} + +// ============================================================================ +// TRON API Request/Response Types +// ============================================================================ + +/// Request body for `/wallet/getnowblock`. +#[derive(Serialize)] +struct GetNowBlockRequest {} + +/// Response from `/wallet/getnowblock`. +#[derive(Deserialize, Debug)] +pub struct GetNowBlockResponse { + /// Computed block identifier (not in protobuf, added by the HTTP servlet layer). + /// First 8 bytes duplicate the block number (big-endian) for sortability, remaining 24 bytes + /// are from SHA256 of `block_header.raw_data`. We only need bytes `[8..16]` for TAPOS + /// (`ref_block_hash`). The block number itself comes from `block_header.raw_data.number`. + /// Deserialized from a 64-char hex string. + /// See [`generateBlockId`](https://github.com/tronprotocol/java-tron/blob/1e35f79/common/src/main/java/org/tron/common/utils/Sha256Hash.java#L252-L258). + #[serde(rename = "blockID", deserialize_with = "deserialize_block_id")] + pub block_id: [u8; 32], + /// Block header containing raw block data (number, timestamp, etc.). + pub block_header: BlockHeader, +} + +/// Block header from `/wallet/getnowblock` response. +#[derive(Deserialize, Debug)] +pub struct BlockHeader { + pub raw_data: BlockRawData, +} + +/// Raw block data from a TRON block header. +#[derive(Deserialize, Debug)] +pub struct BlockRawData { + /// Block height. + pub number: i64, + /// Block timestamp in milliseconds since Unix epoch. + #[serde(default)] + pub timestamp: i64, +} + +impl GetNowBlockResponse { + /// Validate block number and timestamp are sane, return the header with block number as `u64`. + /// + /// A negative block number or non-positive timestamp means the node returned bad data. + /// Returns `BadResponse` (retryable) to trigger rotation to another node. + fn validated_header(&self) -> Web3RpcResult<(&BlockHeader, u64)> { + let number = self.block_header.raw_data.number; + if number < 0 { + return Err(Web3RpcError::BadResponse(format!( + "TRON node returned invalid negative block number: {number}" + )) + .into()); + } + let timestamp = self.block_header.raw_data.timestamp; + if timestamp <= 0 { + return Err( + Web3RpcError::BadResponse(format!("TRON node returned invalid block timestamp: {timestamp}")).into(), + ); + } + Ok((&self.block_header, number as u64)) + } +} + +/// Deserialize a hex string into `[u8; 32]`. +/// +/// Handles TRON's `blockID` field: a 64-char hex string (no `0x` prefix). +/// Returns an error if the hex is invalid or not exactly 32 bytes. +fn deserialize_block_id<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error> +where + D: Deserializer<'de>, +{ + let hex_str = String::deserialize(deserializer)?; + let hex_str = hex_str.strip_prefix("0x").unwrap_or(&hex_str); + let bytes = hex::decode(hex_str).map_err(serde::de::Error::custom)?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|v: Vec| serde::de::Error::custom(format!("blockID must be 32 bytes, got {}", v.len())))?; + Ok(arr) +} + +/// Validated block data needed for TAPOS (Transaction as Proof of Stake) reference. +/// +/// TRON transactions include a reference to a recent block (TAPOS) for replay protection: +/// - `ref_block_bytes`: last 2 bytes of `number` (big-endian) β†’ `number.to_be_bytes()[6..8]` +/// - `ref_block_hash`: bytes 8..16 of `block_id` (the SHA256 portion) +/// +/// TAPOS validity window is 65,536 blocks (~54 hours). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TaposBlockData { + /// Block height. + pub number: u64, + /// Full 32-byte block identifier. + pub block_id: [u8; 32], + /// Block timestamp in milliseconds since Unix epoch. + pub timestamp: i64, +} + +/// Request body for `/wallet/getaccount`. +#[derive(Serialize)] +struct GetAccountRequest<'a> { + address: &'a TronAddress, + /// When `true`, addresses in request/response use Base58Check format (`T...`); + /// when `false`, hex format (`41...`). + visible: bool, +} + +/// Empty object marker for TRON API responses. +/// +/// MUST use `deny_unknown_fields`; otherwise arbitrary error payloads +/// (e.g. `{ "code": "...", "message": "..." }`) could deserialize as `NoAccount` and silently +/// bypass retry/rotation logic. +#[derive(Deserialize, Debug)] +#[serde(deny_unknown_fields)] +pub struct TronEmptyObject {} + +/// Response from `/wallet/getaccount`. +/// +/// TRON returns `{}` for non-existent accounts, or account data for existing ones. +/// Using untagged enum: `Account` (with required `address`) is tried first; if it fails, +/// `NoAccount` matches the empty object. +/// +/// # Proto3 serialization +/// +/// TRON uses proto3 where default values (0, empty) are omitted from JSON. +/// - `address`: Always non-empty for existing accounts (used as DB key), so always serialized. +/// - `balance`: Could be 0 for new accounts, so might be omitted. We use `#[serde(default)]`. +/// - `create_time`: Set on creation, but omitted if 0. We use `#[serde(default)]`. +/// +/// # Extensibility +/// +/// `Account` does NOT use `deny_unknown_fields` so additional fields returned by TRON +/// (like `net_usage`, `assetV2`, `frozenV2`, `owner_permission`, etc.) are silently ignored. +/// Add fields here as needed for future functionality. +/// +/// See: +#[derive(Deserialize, Debug)] +#[serde(untagged)] +pub enum GetAccountResponse { + /// Existing account. `address` is always present (non-empty bytes in protobuf). + Account { + /// Account address in hex format. + address: String, + /// Balance in SUN (1 TRX = 1,000,000 SUN). Defaults to 0 if omitted (proto3). + #[serde(default)] + balance: u64, + /// Account creation timestamp in milliseconds. Defaults to 0 if omitted. + #[serde(default)] + create_time: i64, + }, + /// Empty object `{}` - account doesn't exist on chain. + NoAccount(TronEmptyObject), +} + +impl GetAccountResponse { + /// Returns true if the account exists on chain (used for HD gap-limit scanning). + pub fn exists_meaningfully(&self) -> bool { + matches!(self, GetAccountResponse::Account { .. }) + } +} + +/// Request body for `/wallet/triggerconstantcontract`. +/// +/// Used to call constant (view/pure) functions on smart contracts without broadcasting. +/// For TRC20: `balanceOf(address)`, `decimals()`, `name()`, `symbol()`. +/// +/// Note: Uses `visible: true` so addresses serialize as Base58 (TronAddress default). +#[derive(Serialize)] +struct TriggerConstantContractRequest<'a> { + /// Caller address (required by TRON even for constant calls). + owner_address: &'a TronAddress, + /// Contract address to call. + contract_address: &'a TronAddress, + /// Function signature, e.g. "balanceOf(address)" or "decimals()". + function_selector: &'a str, + /// ABI-encoded parameters (hex string, no 0x prefix, excludes 4-byte selector). + parameter: &'a str, + /// If true, addresses are Base58 format; if false, hex with 0x41 prefix. + /// Must be true since TronAddress serializes to Base58. + visible: bool, +} + +/// Partial response from TRON's `triggerconstantcontract` endpoint. +/// +/// The endpoint returns a `TransactionExtention` protobuf message containing the +/// full simulated transaction, logs, and execution results. We only deserialize the +/// fields needed for fee estimation (`energy_used`) and balance queries (`constant_result`) +/// and omit: +/// - `transaction`: The simulated transaction object (not broadcast for constant calls) +/// - `energy_penalty`: Additional energy penalty for certain contract patterns +/// - `result`: Success/failure indicator (handled by `tron_error_from_value()` before deserialization) +/// - `txid`: Transaction hash of the simulated transaction +/// +/// Error responses are handled by `tron_error_from_value()` before deserialization. +#[derive(Deserialize, Debug)] +pub struct TriggerConstantContractResponse { + /// ABI-encoded return values (protobuf: `repeated bytes`). + /// TRON serializes as hex strings (no 0x prefix). For single return value functions, + /// this contains one element. Decoded via `hex::decode` in `parse_constant_result_u256`. + #[serde(default)] + pub constant_result: Vec, + + /// Energy consumed by the TVM simulation (constant calls execute in a sandbox + /// without broadcasting, so this is precise, not a rough estimate). + /// Used to predict the fee for the actual TRC20 `transfer()` transaction. + #[serde(default)] + pub energy_used: Option, +} + +/// Request body for `/wallet/getchainparameters`. +#[derive(Serialize)] +struct GetChainParametersRequest {} + +/// Response from `/wallet/getchainparameters`. +/// +/// The HTTP API uses camelCase `chainParameter` in live responses. +#[derive(Clone, Debug, Deserialize)] +struct GetChainParametersResponse { + #[serde(rename = "chainParameter", alias = "chain_parameter")] + chain_parameter: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct ChainParameterEntry { + key: String, + /// Some chain parameters are boolean-like flags and omit `value` in JSON. + value: Option, +} + +/// Request body for `/wallet/getaccountresource`. +#[derive(Serialize)] +struct GetAccountResourceRequest<'a> { + address: &'a TronAddress, + visible: bool, +} + +/// Request body for `/wallet/broadcasthex`. +#[derive(Serialize)] +struct BroadcastHexRequest<'a> { + transaction: &'a str, +} + +/// Response from `/wallet/broadcasthex` on success. +/// +/// Error responses (`result: false`) are intercepted by `tron_error_from_value()` before +/// deserialization, so this struct only captures the success-path field. +/// `txid` is always present β€” it is computed from the transaction hash before broadcast. +#[derive(Debug, Deserialize)] +pub struct BroadcastHexResponse { + pub txid: String, +} + +/// Request body for transaction lookup by hash. +#[derive(Serialize)] +struct TxByIdRequest<'a> { + value: &'a str, +} + +/// Response from `/wallet/gettransactionbyid`. +#[derive(Debug, Deserialize)] +pub struct GetTransactionByIdResponse { + #[serde(rename = "txID")] + pub tx_id: String, + pub raw_data: TronTxRawData, +} + +#[derive(Debug, Deserialize)] +pub struct TronTxRawData { + pub contract: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct TronTxContract { + #[serde(rename = "type")] + pub contract_type: String, + pub parameter: TronTxContractParameter, +} + +#[derive(Debug, Deserialize)] +pub struct TronTxContractParameter { + pub value: TronTxContractValue, +} + +#[derive(Debug, Deserialize)] +pub struct TronTxContractValue { + pub contract_address: Option, + pub data: Option, +} + +/// Response from `/wallet/gettransactioninfobyid`. +/// Request struct: [`TxByIdRequest`] (shared with `gettransactionbyid`). +#[derive(Debug, Deserialize)] +pub struct GetTransactionInfoByIdResponse { + pub id: String, + #[serde(rename = "blockNumber")] + pub block_number: u64, + pub receipt: TronTxReceipt, + #[serde(default)] + pub fee: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TronTxReceipt { + #[serde(default)] + pub energy_usage_total: u64, + #[serde(default)] + pub net_fee: u64, + #[serde(default)] + pub energy_fee: u64, + pub result: String, +} + +fn parse_chain_prices_sun(chain_params: &GetChainParametersResponse) -> Web3RpcResult { + let mut bandwidth_price_sun = None; + let mut energy_price_sun = None; + + for param in &chain_params.chain_parameter { + match param.key.as_str() { + "getTransactionFee" => bandwidth_price_sun = param.value.and_then(|v| v.try_into().ok()), + "getEnergyFee" => energy_price_sun = param.value.and_then(|v| v.try_into().ok()), + _ => {}, + } + } + + let bandwidth_price_sun = bandwidth_price_sun.ok_or_else(|| { + Web3RpcError::BadResponse("Missing or invalid getTransactionFee in getchainparameters response".to_owned()) + })?; + let energy_price_sun = energy_price_sun.ok_or_else(|| { + Web3RpcError::BadResponse("Missing or invalid getEnergyFee in getchainparameters response".to_owned()) + })?; + + if bandwidth_price_sun == 0 || energy_price_sun == 0 { + return Err(Web3RpcError::BadResponse( + "Invalid chain prices: getTransactionFee/getEnergyFee must be greater than zero".to_owned(), + ) + .into()); + } + + Ok(TronChainPrices { + bandwidth_price_sun, + energy_price_sun, + }) +} + +// ============================================================================ +// High-level TRON API methods +// ============================================================================ + +impl TronHttpClient { + /// Get the current block. + pub async fn get_now_block(&self) -> Web3RpcResult { + self.post("/wallet/getnowblock", &GetNowBlockRequest {}).await + } + + /// Get validated block data for TAPOS transaction references. + /// + /// Calls `/wallet/getnowblock` and validates that `blockID`, block number, and timestamp + /// are all present and sane. Returns `BadResponse` (retryable) for missing/invalid data. + pub async fn get_block_for_tapos(&self) -> Web3RpcResult { + let response = self.get_now_block().await?; + let (header, number) = response.validated_header()?; + Ok(TaposBlockData { + number, + block_id: response.block_id, + timestamp: header.raw_data.timestamp, + }) + } + + /// Get account information for a TRON address. + pub async fn get_account(&self, address: &TronAddress) -> Web3RpcResult { + let request = GetAccountRequest { address, visible: true }; + self.post("/wallet/getaccount", &request).await + } + + /// Call a constant (view/pure) function on a smart contract. + /// + /// This is the low-level method; for TRC20-specific calls, use `TronApiClient::trc20_balance_of` + /// or `TronApiClient::trc20_decimals` which handle ABI encoding and node rotation. + pub async fn trigger_constant_contract( + &self, + owner_address: &TronAddress, + contract_address: &TronAddress, + function_selector: &str, + parameter: &str, + ) -> Web3RpcResult { + let request = TriggerConstantContractRequest { + owner_address, + contract_address, + function_selector, + parameter, + visible: true, + }; + self.post("/wallet/triggerconstantcontract", &request).await + } + + /// Fetch TRON chain fee prices from `/wallet/getchainparameters`. + /// + /// Returns `BadResponse` (retryable) when required fee parameters are missing, malformed, + /// negative, or zero so callers can rotate to the next node. + pub async fn get_chain_prices(&self) -> Web3RpcResult { + let response: GetChainParametersResponse = self + .post("/wallet/getchainparameters", &GetChainParametersRequest {}) + .await?; + parse_chain_prices_sun(&response) + } + + /// Get account resource usage and limits. + /// + /// Returns `TronAccountResources` with bandwidth and energy quotas. + /// Empty `{}` responses (unactivated accounts) produce all-zero resources. + pub async fn get_account_resource(&self, address: &TronAddress) -> Web3RpcResult { + let request = GetAccountResourceRequest { address, visible: true }; + self.post("/wallet/getaccountresource", &request).await + } + + /// Broadcast a signed transaction (hex-encoded protobuf bytes). + /// + /// Error responses (`result: false`) are handled by `tron_error_from_value()` in `post()`. + pub async fn broadcast_hex(&self, tx_hex: &str) -> Web3RpcResult { + self.post("/wallet/broadcasthex", &BroadcastHexRequest { transaction: tx_hex }) + .await + } + + /// Get raw transaction by hash. + pub async fn get_transaction_by_id(&self, tx_id: &str) -> Web3RpcResult { + self.post("/wallet/gettransactionbyid", &TxByIdRequest { value: tx_id }) + .await + } + + /// Get transaction execution info/receipt by hash. + pub async fn get_transaction_info_by_id(&self, tx_id: &str) -> Web3RpcResult { + self.post("/wallet/gettransactioninfobyid", &TxByIdRequest { value: tx_id }) + .await + } +} + +// ============================================================================ +// TRON API Client (node rotation) +// ============================================================================ + +use futures::lock::Mutex as AsyncMutex; + +/// Pool of TRON HTTP clients with rotation on success. +#[derive(Clone)] +pub struct TronApiClient { + clients: Arc>>, +} + +impl TronApiClient { + pub fn new(clients: Vec) -> Self { + Self { + clients: Arc::new(AsyncMutex::new(clients)), + } + } + + /// Execute an operation with node rotation. + /// Tries each node until one succeeds, rotating successful nodes to front. + /// + /// Retryability is determined by `Web3RpcError::is_retryable()`: + /// - **Retryable** (`Transport`, `Timeout`, `BadResponse`): try next node. Includes network failures/timeouts, + /// malformed JSON/unexpected payloads, and transient TRON conditions (SERVER_BUSY, etc.). + /// - **Non-retryable** (`RemoteError`, `InvalidResponse`, `Internal`, etc.): fail immediately. Includes + /// deterministic TRON rejections (CONTRACT_VALIDATE_ERROR, SIGERROR, etc.) and programming errors. + /// + /// Note: Holds mutex across await for consistency with EVM's `try_rpc_send` pattern. + async fn try_clients(&self, op: F) -> Web3RpcResult + where + F: Fn(TronHttpClient) -> Fut, + Fut: std::future::Future>, + { + let mut clients = self.clients.lock().await; + + if clients.is_empty() { + return Err(Web3RpcError::Transport("No TRON API nodes configured".to_string()).into()); + } + + let mut last_retryable: Option = None; + + for (i, client) in clients.clone().into_iter().enumerate() { + match op(client).await { + Ok(result) => { + // Rotate successful client to front + clients.rotate_left(i); + return Ok(result); + }, + Err(e) => { + let inner = e.into_inner(); + if inner.is_retryable() { + last_retryable = Some(inner); + continue; + } + // Non-retryable error, fail fast + return Err(inner.into()); + }, + } + } + + Err(last_retryable + .unwrap_or_else(|| Web3RpcError::Transport("All TRON nodes unreachable".to_string())) + .into()) + } + + /// Get validated block data for TAPOS with node rotation. + pub async fn get_block_for_tapos(&self) -> Web3RpcResult { + self.try_clients(|client| async move { client.get_block_for_tapos().await }) + .await + } + + /// Get account information with node rotation. + pub async fn get_account(&self, address: &TronAddress) -> Web3RpcResult { + self.try_clients(|client| { + let addr = *address; + async move { client.get_account(&addr).await } + }) + .await + } + + /// Get TRC20 token balance for an account with node rotation. + /// + /// Calls `balanceOf(address)` on the TRC20 contract. + /// Returns balance as U256 (raw token units, not adjusted for decimals). + pub async fn trc20_balance_of(&self, contract: &TronAddress, account: &TronAddress) -> Web3RpcResult { + let parameter = abi_encode_address_param(account); + self.try_clients(|client| { + let contract = *contract; + let account = *account; + let param = parameter.clone(); + async move { + let response = client + .trigger_constant_contract(&account, &contract, "balanceOf(address)", ¶m) + .await?; + parse_constant_result_u256(&response) + } + }) + .await + } + + /// Get TRC20 token decimals with node rotation. + /// + /// Calls `decimals()` on the TRC20 contract. + /// Returns decimals as u8 (typically 6 for USDT, 18 for most tokens). + pub async fn trc20_decimals(&self, contract: &TronAddress, caller: &TronAddress) -> Web3RpcResult { + self.try_clients(|client| { + let contract = *contract; + let caller = *caller; + async move { + let response = client + .trigger_constant_contract(&caller, &contract, "decimals()", "") + .await?; + let value = parse_constant_result_u256(&response)?; + + // Decimals must fit in u8 (0-255) + if value > U256::from(255u8) { + return Err(Web3RpcError::InvalidResponse(format!( + "TRC20 decimals value {} exceeds u8 range", + value + )) + .into()); + } + Ok(value.as_u32() as u8) + } + }) + .await + } + + /// Fetch validated TRON chain fee prices with node rotation. + /// + /// Invalid fee parameter payloads are treated as retryable (`BadResponse`) and trigger + /// fallback to the next node. + pub async fn get_chain_prices(&self) -> Web3RpcResult { + self.try_clients(|client| async move { client.get_chain_prices().await }) + .await + } + + /// Get account resource usage and limits with node rotation. + pub async fn get_account_resource(&self, address: &TronAddress) -> Web3RpcResult { + self.try_clients(|client| { + let addr = *address; + async move { client.get_account_resource(&addr).await } + }) + .await + } + + /// Broadcast a signed transaction (hex-encoded protobuf bytes) with node rotation. + pub async fn broadcast_hex(&self, tx_hex: &str) -> Web3RpcResult { + let tx_hex = tx_hex.to_owned(); + self.try_clients(|client| { + let hex = tx_hex.clone(); + async move { client.broadcast_hex(&hex).await } + }) + .await + } + + /// Get raw transaction by hash with node rotation. + pub async fn get_transaction_by_id(&self, tx_id: &str) -> Web3RpcResult { + let tx_id = tx_id.to_owned(); + self.try_clients(|client| { + let tx_id = tx_id.clone(); + async move { client.get_transaction_by_id(&tx_id).await } + }) + .await + } + + /// Get transaction execution info/receipt by hash with node rotation. + pub async fn get_transaction_info_by_id(&self, tx_id: &str) -> Web3RpcResult { + let tx_id = tx_id.to_owned(); + self.try_clients(|client| { + let tx_id = tx_id.clone(); + async move { client.get_transaction_info_by_id(&tx_id).await } + }) + .await + } + + /// Call a constant (view/pure) function on a smart contract with node rotation. + pub async fn trigger_constant_contract( + &self, + owner_address: &TronAddress, + contract_address: &TronAddress, + function_selector: &str, + parameter: &str, + ) -> Web3RpcResult { + let owner = *owner_address; + let contract = *contract_address; + let selector = function_selector.to_owned(); + let param = parameter.to_owned(); + self.try_clients(move |client| { + let selector = selector.clone(); + let param = param.clone(); + async move { + client + .trigger_constant_contract(&owner, &contract, &selector, ¶m) + .await + } + }) + .await + } + + /// Estimate energy required for a TRC20 `transfer(address,uint256)` call. + /// + /// ABI-encodes the transfer parameters and calls `trigger_constant_contract`. + /// Returns the `energy_used` from the response. If `energy_used` is missing or zero, + /// returns `BadResponse` to trigger node rotation. + pub async fn estimate_trc20_transfer_energy( + &self, + owner: &TronAddress, + contract: &TronAddress, + recipient: &TronAddress, + amount: U256, + ) -> Web3RpcResult { + let params_hex = abi_encode_trc20_transfer_params(recipient, amount); + let response = self + .trigger_constant_contract(owner, contract, "transfer(address,uint256)", ¶ms_hex) + .await?; + match response.energy_used { + Some(energy) if energy > 0 => Ok(energy), + _ => Err(Web3RpcError::BadResponse( + "trigger_constant_contract returned no energy_used for TRC20 transfer estimation".to_owned(), + ) + .into()), + } + } +} + +// ============================================================================ +// TRC20 ABI Helpers +// ============================================================================ + +/// Encode an address as a 32-byte ABI parameter (hex string, no 0x prefix). +/// +/// For `balanceOf(address)`, the parameter is the account address encoded as: +/// - 12 zero bytes (left padding) +/// - 20-byte raw EVM address (from `TronAddress::to_evm_address()`) +/// +/// Uses standard 20-byte EVM ABI encoding, NOT TRON's 21-byte format with 0x41 prefix. +fn abi_encode_address_param(addr: &TronAddress) -> String { + let evm_addr = addr.to_evm_address(); + let mut padded = [0u8; 32]; + padded[12..32].copy_from_slice(evm_addr.as_bytes()); + hex::encode(padded) +} + +/// Encode TRC20 `transfer(address,uint256)` parameters as a hex string (no 0x prefix). +/// +/// The parameters are ABI-encoded as two 32-byte slots: +/// - recipient address (left-padded to 32 bytes, 20-byte EVM format) +/// - amount as uint256 +/// +/// The function selector is NOT included β€” TRON's `function_selector` field handles that. +fn abi_encode_trc20_transfer_params(recipient: &TronAddress, amount: U256) -> String { + let tokens = trc20_transfer_tokens(recipient, amount); + hex::encode(ethabi::encode(&tokens)) +} + +/// Parse the first constant_result element as U256. +/// +/// TRON returns `constant_result` as `repeated bytes` (protobuf), serialized as hex strings +/// without 0x prefix. This function decodes the hex, validates, and converts to U256. +fn parse_constant_result_u256(response: &TriggerConstantContractResponse) -> Web3RpcResult { + // Empty constant_result can occur due to node-specific issues: + // - Node out of sync (different latest block state) + // - Resource limits (OutOfTimeException on overloaded nodes) + // - TVM configuration differences between nodes + // BadResponse is used to trigger rotation - another node may succeed. + let hex_str = response.constant_result.first().ok_or_else(|| { + Web3RpcError::BadResponse( + "TRON constant_result is empty - node may be out of sync or resource-constrained".to_string(), + ) + })?; + + // Decode hex string to bytes. Invalid hex from a node should trigger rotation. + let bytes = hex::decode(hex_str).map_err(|e| { + Web3RpcError::BadResponse(format!( + "TRON constant_result contains invalid hex '{}': {}", + hex_str, e + )) + })?; + + // Oversized result (>32 bytes) indicates wrong return type from contract. + // This is probably deterministic and would fail on all nodes - InvalidResponse is used. + if bytes.len() > 32 { + return Err(Web3RpcError::InvalidResponse(format!( + "constant_result too large: {} bytes (max 32) - contract may return wrong type", + bytes.len() + )) + .into()); + } + + // Left-pad to 32 bytes and convert to U256 + let mut padded = [0u8; 32]; + padded[32 - bytes.len()..].copy_from_slice(&bytes[..]); + Ok(U256::from_big_endian(&padded)) +} + +// ============================================================================ +// ChainRpcOps implementation for TronApiClient +// ============================================================================ + +use crate::eth::chain_rpc::ChainRpcOps; +use async_trait::async_trait; +use mm2_err_handle::prelude::MmError; + +#[async_trait] +impl ChainRpcOps for TronApiClient { + type Error = MmError; + type Address = TronAddress; + type Balance = U256; + + async fn current_block(&self) -> Result { + self.try_clients(|client| async move { + let response = client.get_now_block().await?; + let (_header, number) = response.validated_header()?; + Ok(number) + }) + .await + } + + async fn balance_native(&self, address: Self::Address) -> Result { + self.try_clients(|client| { + let addr = address; + async move { + let account = client.get_account(&addr).await?; + let balance = match account { + GetAccountResponse::Account { balance, .. } => balance, + // Address might have been created by KDF and not used on-chain yet. Return 0. + GetAccountResponse::NoAccount(_) => 0, + }; + Ok(U256::from(balance)) + } + }) + .await + } + + async fn is_address_used_basic(&self, address: Self::Address) -> Result { + self.try_clients(|client| { + let addr = address; + async move { + let account = client.get_account(&addr).await?; + Ok(account.exists_meaningfully()) + } + }) + .await + } +} + +impl std::fmt::Debug for TronApiClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TronApiClient").finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verifies the custom `blockID` hex deserializer parses correctly and that the + /// block number embedded in `blockID[0..8]` matches `block_header.raw_data.number`. + /// Test data: Nile testnet block [54242114](https://nile.tronscan.org/#/block/54242114). + #[test] + fn parse_getnowblock_and_tapos_derivation() { + let json = r#"{ + "blockID": "00000000033bab42567444cc8af3dbaeb5cf26b514b7e90b9a23424ea8392641", + "block_header": { + "raw_data": { + "number": 54242114, + "timestamp": 1738799040000 + } + } + }"#; + let resp: GetNowBlockResponse = serde_json::from_str(json).unwrap(); + + assert_eq!(resp.block_header.raw_data.number, 54_242_114); + assert_eq!(resp.block_header.raw_data.timestamp, 1_738_799_040_000); + + // Block number embedded in blockID[0..8] matches block_header + let number_from_id = u64::from_be_bytes(resp.block_id[..8].try_into().unwrap()); + assert_eq!(number_from_id, 54_242_114); + } + + /// Non-hex `blockID` must fail deserialization (triggers `BadResponse` β†’ node rotation). + #[test] + fn parse_getnowblock_rejects_invalid_block_id_hex() { + let json = r#"{ "blockID": "zz00000000000000000000000000000000000000000000000000000000000000", "block_header": { "raw_data": { "number": 1 } } }"#; + let err = serde_json::from_str::(json).unwrap_err(); + assert!(err.is_data(), "Expected data error for invalid hex, got: {}", err); + assert!( + err.to_string().contains("Invalid character"), + "Expected hex parse error, got: {}", + err + ); + } + + /// `blockID` that isn't exactly 32 bytes must fail deserialization. + #[test] + fn parse_getnowblock_rejects_wrong_length_block_id() { + // 31 bytes (62 hex chars) β€” too short + let json = r#"{ "blockID": "00000000033bab42e37d025dc14e9ebc26e8f6cb6b6e26e08d2bf2db29c3b4", "block_header": { "raw_data": { "number": 1 } } }"#; + let err = serde_json::from_str::(json).unwrap_err(); + assert!(err.is_data(), "Expected data error for wrong length, got: {}", err); + assert!( + err.to_string().contains("blockID must be 32 bytes"), + "Expected length error, got: {}", + err + ); + } + + #[test] + fn parse_chain_prices_accepts_valid_parameters() { + let response = GetChainParametersResponse { + chain_parameter: vec![ + ChainParameterEntry { + key: "getTransactionFee".to_owned(), + value: Some(1000), + }, + ChainParameterEntry { + key: "getEnergyFee".to_owned(), + value: Some(100), + }, + ], + }; + + let prices = parse_chain_prices_sun(&response).unwrap(); + assert_eq!(prices.bandwidth_price_sun, 1000); + assert_eq!(prices.energy_price_sun, 100); + } + + #[test] + fn parse_chain_prices_rejects_zero_values_as_retryable_bad_response() { + let response = GetChainParametersResponse { + chain_parameter: vec![ + ChainParameterEntry { + key: "getTransactionFee".to_owned(), + value: Some(0), + }, + ChainParameterEntry { + key: "getEnergyFee".to_owned(), + value: Some(100), + }, + ], + }; + + let err = parse_chain_prices_sun(&response).unwrap_err().into_inner(); + assert!(matches!(err, Web3RpcError::BadResponse(_))); + assert!(err.is_retryable()); + } + + #[test] + fn parse_getchainparameters_handles_entries_without_value_field() { + let json = r#"{ + "chainParameter": [ + { "key": "getAllowUpdateAccountName" }, + { "key": "getTransactionFee", "value": 1000 }, + { "key": "getEnergyFee", "value": 100 } + ] + }"#; + + let response: GetChainParametersResponse = serde_json::from_str(json).unwrap(); + let prices = parse_chain_prices_sun(&response).unwrap(); + assert_eq!(prices.bandwidth_price_sun, 1000); + assert_eq!(prices.energy_price_sun, 100); + } + + /// Verifies that all 6 mixed-case field renames map correctly to `TronAccountResources`. + /// Extra fields (TotalNetLimit, etc.) are silently ignored as expected. + #[test] + fn parse_account_resource_canonical_response() { + let json = r#"{ + "freeNetUsed": 100, + "freeNetLimit": 600, + "NetUsed": 30, + "NetLimit": 500, + "EnergyUsed": 200, + "EnergyLimit": 1000, + "TotalNetLimit": 43200000000, + "TotalNetWeight": 84593524300, + "TotalEnergyCurrentLimit": 50000000000, + "TotalEnergyWeight": 12345678 + }"#; + + let resources: TronAccountResources = serde_json::from_str(json).unwrap(); + assert_eq!(resources.free_net_used, 100); + assert_eq!(resources.free_net_limit, 600); + assert_eq!(resources.net_used, 30); + assert_eq!(resources.net_limit, 500); + assert_eq!(resources.energy_used, 200); + assert_eq!(resources.energy_limit, 1000); + } + + /// Empty `{}` (unactivated account / proto3 zero-omission) produces all-zero resources. + #[test] + fn parse_account_resource_empty_response() { + let resources: TronAccountResources = serde_json::from_str("{}").unwrap(); + assert_eq!(resources, TronAccountResources::default()); + } + + /// `tron_error_from_value` catches `result: false` before deserialization, + /// so `BroadcastHexResponse` only needs to handle success responses. + #[test] + fn broadcast_hex_error_response_caught_by_tron_error_from_value() { + let json = r#"{ + "result": false, + "code": "SIGERROR", + "message": "Validate signature error", + "txid": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90" + }"#; + + let value: Json = serde_json::from_str(json).unwrap(); + let error = tron_error_from_value(&value); + assert!(error.is_some(), "Error should be detected"); + let error = error.unwrap(); + assert_eq!(error.code.as_deref(), Some("SIGERROR")); + assert!(!error.is_retryable()); + } +} diff --git a/mm2src/coins/eth/tron/api_integration_tests.rs b/mm2src/coins/eth/tron/api_integration_tests.rs new file mode 100644 index 0000000000..894518c9a7 --- /dev/null +++ b/mm2src/coins/eth/tron/api_integration_tests.rs @@ -0,0 +1,579 @@ +//! Integration tests for TRON API client using Nile testnet. +//! +//! These tests make real network calls to the TRON Nile testnet. +//! They are gated behind the `tron-network-tests` feature to avoid running +//! during regular test runs. +//! +//! # Running the tests +//! +//! ```bash +//! # Run all TRON Nile integration tests (native) +//! cargo test -p coins --features tron-network-tests --lib tron_nile +//! +//! # Run a specific test +//! cargo test -p coins --features tron-network-tests --lib tron_nile_current_block +//! +//! # Override API nodes (optional, native only) +//! TRON_NILE_API_URLS="https://nile.trongrid.io" cargo test -p coins --features tron-network-tests --lib tron_nile +//! ``` +//! +//! # WASM tests +//! +//! WASM tests require a browser runner because `mm2_net`'s WASM HTTP transport uses +//! `Window`/`Worker` fetch and doesn't support Node.js. Run with: +//! +//! ```bash +//! wasm-pack test --headless --firefox mm2src/coins --features tron-network-tests -- tron_nile +//! ``` +//! +//! See `docs/DEV_ENVIRONMENT.md` for browser driver setup (geckodriver, environment variables). + +use super::api::{TronApiClient, TronHttpClient, TronHttpNode}; +use super::TronAddress; +use crate::eth::chain_rpc::ChainRpcOps; +use crate::eth::Web3RpcError; +use common::executor::Timer; +use common::{cross_test, small_rng}; +use ethereum_types::Address as EthAddress; +use http::Uri; +use mm2_test_helpers::for_tests::{TRON_NILE_NODES, TRON_TESTNET_KNOWN_ADDRESS}; +use rand::RngCore; +use std::convert::TryInto; + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_test::*; + +#[cfg(target_arch = "wasm32")] +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + +/// Get TRON Nile API URLs from environment or use defaults. +fn tron_nile_urls() -> Vec { + #[cfg(not(target_arch = "wasm32"))] + let from_env = std::env::var("TRON_NILE_API_URLS").ok(); + #[cfg(target_arch = "wasm32")] + let from_env: Option = None; + + let raw_urls: Vec = if let Some(s) = from_env { + s.split([',', ' ']) + .map(str::trim) + .filter(|x| !x.is_empty()) + .map(ToOwned::to_owned) + .collect() + } else { + TRON_NILE_NODES.iter().map(|s| s.to_string()).collect() + }; + + raw_urls + .into_iter() + .map(|url| url.parse().expect("Invalid TRON API URL")) + .collect() +} + +/// Create a TronApiClient for Nile testnet. +fn tron_nile_api_client() -> TronApiClient { + let uris = tron_nile_urls(); + let clients = uris + .into_iter() + .map(|uri| { + TronHttpClient::new( + TronHttpNode { + uri, + komodo_proxy: false, + }, + None, + ) + }) + .collect(); + TronApiClient::new(clients) +} + +/// Parse a TRON base58 address to TronAddress. +fn parse_tron_address(base58: &str) -> TronAddress { + TronAddress::from_base58(base58).expect("Invalid TRON address") +} + +/// Generate a random TRON address for testing unused address scenarios. +fn random_tron_address() -> TronAddress { + let mut rng = small_rng(); + let mut addr_bytes = [0u8; 20]; + rng.fill_bytes(&mut addr_bytes); + let eth_addr = EthAddress::from_slice(&addr_bytes); + TronAddress::from(ð_addr) +} + +/// Create a TronApiClient with a failing node first, then working nodes. +/// This tests the retry/failover behavior. +fn tron_nile_api_client_with_failing_node_first() -> TronApiClient { + let bad_uri: Uri = "http://127.0.0.1:1".parse().expect("Invalid bad URI"); + let good_uris = tron_nile_urls(); + + // Put the bad node first, so the client must retry on good nodes + let mut all_clients = vec![TronHttpClient::new( + TronHttpNode { + uri: bad_uri, + komodo_proxy: false, + }, + None, + )]; + + all_clients.extend(good_uris.into_iter().map(|uri| { + TronHttpClient::new( + TronHttpNode { + uri, + komodo_proxy: false, + }, + None, + ) + })); + + TronApiClient::new(all_clients) +} + +/// Create a TronApiClient with only failing nodes. +/// This tests the transport error handling when all nodes fail. +fn tron_nile_api_client_all_failing() -> TronApiClient { + let bad_uris: Vec = vec![ + "http://127.0.0.1:1".parse().unwrap(), + "http://127.0.0.1:2".parse().unwrap(), + ]; + + let clients = bad_uris + .into_iter() + .map(|uri| { + TronHttpClient::new( + TronHttpNode { + uri, + komodo_proxy: false, + }, + None, + ) + }) + .collect(); + + TronApiClient::new(clients) +} + +// ============================================================================ +// Test Implementation Functions +// ============================================================================ + +async fn test_get_now_block_number_impl() { + let client = tron_nile_api_client(); + let block_number = client.current_block().await.unwrap(); + + // Nile testnet should have millions of blocks by now + assert!( + block_number > 0, + "Block number should be positive, got {}", + block_number + ); + assert!( + block_number > 1_000_000, + "Nile testnet should have more than 1M blocks, got {}", + block_number + ); +} + +async fn test_block_number_non_decreasing_impl() { + let client = tron_nile_api_client(); + + let block1 = client.current_block().await.unwrap(); + // Small delay between calls (cross-platform) + Timer::sleep(0.1).await; + let block2 = client.current_block().await.unwrap(); + + assert!( + block2 >= block1, + "Block number should not decrease: {} -> {}", + block1, + block2 + ); +} + +async fn test_is_address_used_known_impl() { + let client = tron_nile_api_client(); + let address = parse_tron_address(TRON_TESTNET_KNOWN_ADDRESS); + + let is_used = client.is_address_used_basic(address).await.unwrap(); + + assert!( + is_used, + "Known testnet address {} should be marked as used", + TRON_TESTNET_KNOWN_ADDRESS + ); +} + +async fn test_is_address_used_unused_impl() { + let client = tron_nile_api_client(); + let address = random_tron_address(); + + let is_used = client.is_address_used_basic(address).await.unwrap(); + + assert!(!is_used, "Random address should not be marked as used"); +} + +async fn test_balance_native_impl() { + let client = tron_nile_api_client(); + let address = parse_tron_address(TRON_TESTNET_KNOWN_ADDRESS); + + let balance = client.balance_native(address).await.unwrap(); + + assert!( + balance > ethereum_types::U256::zero(), + "Known testnet address {} should have non-zero TRX balance", + TRON_TESTNET_KNOWN_ADDRESS + ); +} + +async fn test_get_block_for_tapos_impl() { + let client = tron_nile_api_client(); + let tapos = client.get_block_for_tapos().await.unwrap(); + + assert!( + tapos.number > 1_000_000, + "Nile testnet should have more than 1M blocks, got {}", + tapos.number + ); + assert!( + tapos.timestamp > 0, + "Block timestamp should be positive, got {}", + tapos.timestamp + ); + + // blockID first 8 bytes encode the block number in big-endian + let number_from_id = u64::from_be_bytes(tapos.block_id[..8].try_into().unwrap()); + assert_eq!( + number_from_id, tapos.number, + "Block number in blockID should match block_header.raw_data.number" + ); +} + +// ============================================================================ +// Cross-Platform Integration Tests +// ============================================================================ + +cross_test!(tron_nile_current_block, { + test_get_now_block_number_impl().await; +}); + +cross_test!(tron_nile_block_number_non_decreasing, { + test_block_number_non_decreasing_impl().await; +}); + +cross_test!(tron_nile_is_address_used_known, { + test_is_address_used_known_impl().await; +}); + +cross_test!(tron_nile_is_address_used_unused, { + test_is_address_used_unused_impl().await; +}); + +cross_test!(tron_nile_balance_native, { + test_balance_native_impl().await; +}); + +cross_test!(tron_nile_get_block_for_tapos, { + test_get_block_for_tapos_impl().await; +}); + +// ============================================================================ +// Error Response Tests +// ============================================================================ +// These tests verify that our error detection handles real TRON API error responses. + +use serde::{Deserialize, Serialize}; + +/// Create a single TronHttpClient for error tests (no rotation needed). +fn tron_nile_single_client() -> TronHttpClient { + let uri = tron_nile_urls() + .into_iter() + .next() + .expect("At least one TRON node expected"); + TronHttpClient::new( + TronHttpNode { + uri, + komodo_proxy: false, + }, + None, + ) +} + +async fn test_error_nested_result_detection_impl() { + // Call triggerconstantcontract with a non-existent contract address + // This tests our nested result error detection: + // {"result": {"result": false, "code": "CONTRACT_VALIDATE_ERROR", "message": "..."}} + let client = tron_nile_single_client(); + + let owner = parse_tron_address(TRON_TESTNET_KNOWN_ADDRESS); + // Use a random address that is definitely not a contract + let non_contract = random_tron_address(); + + // Uses the public trigger_constant_contract method (same post() + error detection path) + let result = client + .trigger_constant_contract( + &owner, + &non_contract, + "balanceOf(address)", + "0000000000000000000000000000000000000000000000000000000000000001", + ) + .await; + + // Should be an error because our error detection catches nested {"result": {"result": false, ...}} + assert!(result.is_err(), "Expected error for non-existent contract"); + + let err = result.unwrap_err().into_inner(); + + // Verify the error is a RemoteError with CONTRACT_VALIDATE_ERROR code + match err { + Web3RpcError::RemoteError { code, message } => { + assert_eq!(code.as_deref(), Some("CONTRACT_VALIDATE_ERROR")); + assert!( + message.contains("Smart contract"), + "Expected message to contain 'Smart contract', got: {}", + message + ); + }, + other => panic!("Expected RemoteError, got {:?}", other), + } +} + +async fn test_error_invalid_endpoint_impl() { + // Call a non-existent endpoint to test HTTP error handling + let client = tron_nile_single_client(); + + #[derive(Serialize)] + struct EmptyRequest {} + + #[derive(Deserialize, Debug)] + struct AnyResponse {} + + let result: Result = client + .post("/wallet/nonexistent_endpoint_12345", &EmptyRequest {}) + .await; + + let err = result.unwrap_err().into_inner(); + match err { + Web3RpcError::Transport(msg) => { + // 405 Method Not Allowed are returned for non-existent endpoints + assert!( + msg.starts_with("TRON API returned status 405"), + "Expected HTTP 405 status error, got: {}", + msg + ); + }, + other => panic!("Expected Web3RpcError::Transport, got {:?}", other), + } +} + +async fn test_error_empty_response_handling_impl() { + // Verify that an empty account response (non-existent account) is handled correctly + // and does NOT trigger our error detection (since {} is a valid "account not found" response) + let client = tron_nile_api_client(); + let address = random_tron_address(); + + // This should succeed and return an empty account, not an error + let result = client.get_account(&address).await; + assert!(result.is_ok(), "Empty account response should not be treated as error"); + + let account = result.unwrap(); + assert!( + !account.exists_meaningfully(), + "Random address should return non-existent account" + ); +} + +cross_test!(tron_nile_error_nested_result_detection, { + test_error_nested_result_detection_impl().await; +}); + +cross_test!(tron_nile_error_invalid_endpoint, { + test_error_invalid_endpoint_impl().await; +}); + +cross_test!(tron_nile_error_empty_response_handling, { + test_error_empty_response_handling_impl().await; +}); + +// ============================================================================ +// Fee Validation Tests (TRC20) +// ============================================================================ + +const NILE_KNOWN_TRC20_TX_HASH: &str = "b4eaf9c10802e20ad757c701fca45616c71fa68c84dea4110f6772005a480fa4"; +const NILE_KNOWN_TRC20_BLOCK_NUMBER: u64 = 64_844_180; +const NILE_KNOWN_TRC20_CONTRACT_ADDRESS: &str = "41eca9bc828a3005b9a3b909f2cc5c2a54794de05f"; +const NILE_KNOWN_TRC20_TRANSFER_SELECTOR: &str = "a9059cbb"; +const NILE_KNOWN_TRC20_FEE_SUN: u64 = 345_000; + +async fn test_known_trc20_tx_fee_receipt_impl() { + let client = tron_nile_single_client(); + + let tx = client + .get_transaction_by_id(NILE_KNOWN_TRC20_TX_HASH) + .await + .expect("Known TRC20 transaction should be available on Nile"); + assert_eq!(tx.tx_id, NILE_KNOWN_TRC20_TX_HASH); + + let first_contract = tx + .raw_data + .contract + .first() + .expect("Known TRC20 transaction should contain at least one contract"); + + assert_eq!(first_contract.contract_type, "TriggerSmartContract"); + assert_eq!( + first_contract.parameter.value.contract_address.as_deref(), + Some(NILE_KNOWN_TRC20_CONTRACT_ADDRESS) + ); + + let data = first_contract + .parameter + .value + .data + .as_deref() + .expect("TriggerSmartContract data must be present"); + assert!( + data.starts_with(NILE_KNOWN_TRC20_TRANSFER_SELECTOR), + "Expected TRC20 transfer selector prefix {}, got {}", + NILE_KNOWN_TRC20_TRANSFER_SELECTOR, + data + ); + + let tx_info = client + .get_transaction_info_by_id(NILE_KNOWN_TRC20_TX_HASH) + .await + .expect("Known TRC20 transaction info should be available on Nile"); + assert_eq!(tx_info.id, NILE_KNOWN_TRC20_TX_HASH); + assert_eq!(tx_info.block_number, NILE_KNOWN_TRC20_BLOCK_NUMBER); + assert_eq!(tx_info.receipt.result, "SUCCESS"); + assert!(tx_info.receipt.energy_usage_total > 0); + assert_eq!(tx_info.receipt.energy_fee, 0); + assert_eq!(tx_info.receipt.net_fee, NILE_KNOWN_TRC20_FEE_SUN); + assert_eq!(tx_info.fee.unwrap_or_default(), NILE_KNOWN_TRC20_FEE_SUN); +} + +async fn test_chain_fee_parameters_are_present_and_valid_impl() { + let client = tron_nile_api_client(); + let chain_prices = client + .get_chain_prices() + .await + .expect("getchainparameters should be available and valid on Nile"); + + assert!(chain_prices.bandwidth_price_sun > 0); + assert!(chain_prices.energy_price_sun > 0); +} + +cross_test!(tron_nile_known_trc20_tx_fee_receipt, { + test_known_trc20_tx_fee_receipt_impl().await; +}); + +cross_test!(tron_nile_chain_fee_parameters_are_present_and_valid, { + test_chain_fee_parameters_are_present_and_valid_impl().await; +}); + +// ============================================================================ +// Node Rotation and Retry Tests +// ============================================================================ +// These tests verify the retry/failover behavior when nodes fail. + +async fn test_retry_on_transport_failure_impl() { + // Create a client with a failing node first, then working nodes. + // The request should succeed by retrying on the working nodes. + let client = tron_nile_api_client_with_failing_node_first(); + + // Should succeed by retrying on working nodes after transport failure + let result = client.current_block().await; + + assert!( + result.is_ok(), + "Request should succeed by retrying on working nodes after transport failure: {:?}", + result.err() + ); + + let block_number = result.unwrap(); + assert!( + block_number > 1_000_000, + "Should get a valid block number from the working node" + ); +} + +async fn test_all_nodes_failing_returns_transport_error_impl() { + // Create a client with only failing nodes. + // The request should fail with a transport error after trying all nodes. + let client = tron_nile_api_client_all_failing(); + + let result = client.current_block().await; + + assert!(result.is_err(), "Request should fail when all nodes are unreachable"); + + let error = result.unwrap_err(); + let inner = error.into_inner(); + + // The last error should be a transport error (retryable) + // since all failures were connection failures. + assert!( + inner.is_retryable(), + "Final error should be Transport (retryable) when all nodes fail: {:?}", + inner + ); + + assert!( + matches!(inner, Web3RpcError::Transport(_)), + "Expected Web3RpcError::Transport, got {:?}", + inner + ); +} + +cross_test!(tron_nile_retry_on_transport_failure, { + test_retry_on_transport_failure_impl().await; +}); + +cross_test!(tron_nile_all_nodes_failing_returns_transport_error, { + test_all_nodes_failing_returns_transport_error_impl().await; +}); + +// ============================================================================ +// Account Resource Tests +// ============================================================================ + +async fn test_get_account_resource_known_address_impl() { + let client = tron_nile_api_client(); + let address = parse_tron_address(TRON_TESTNET_KNOWN_ADDRESS); + + let resources = client + .get_account_resource(&address) + .await + .expect("getaccountresource should succeed for known address"); + + // Known testnet address should have at least the free bandwidth limit + assert!( + resources.free_net_limit > 0, + "Known address should have non-zero freeNetLimit, got {}", + resources.free_net_limit + ); +} + +async fn test_get_account_resource_unactivated_address_impl() { + let client = tron_nile_api_client(); + let address = random_tron_address(); + + let resources = client + .get_account_resource(&address) + .await + .expect("getaccountresource should succeed for unactivated address (empty {} response)"); + + // Unactivated address returns empty {} which deserializes to all zeros + assert_eq!(resources.free_net_used, 0); + assert_eq!(resources.free_net_limit, 0); + assert_eq!(resources.net_used, 0); + assert_eq!(resources.net_limit, 0); + assert_eq!(resources.energy_used, 0); + assert_eq!(resources.energy_limit, 0); +} + +cross_test!(tron_nile_get_account_resource_known_address, { + test_get_account_resource_known_address_impl().await; +}); + +cross_test!(tron_nile_get_account_resource_unactivated_address, { + test_get_account_resource_unactivated_address_impl().await; +}); diff --git a/mm2src/coins/eth/tron/fee.rs b/mm2src/coins/eth/tron/fee.rs new file mode 100644 index 0000000000..6de326f609 --- /dev/null +++ b/mm2src/coins/eth/tron/fee.rs @@ -0,0 +1,329 @@ +//! TRON fee estimation for bandwidth and energy costs. +//! +//! Bandwidth is charged per byte of serialized transaction; energy is charged per +//! unit consumed by smart contract execution (TRC20 transfers). Both are priced in +//! SUN and converted to TRX at the current chain rate. + +use super::proto::{Transaction, TransactionRaw}; +use super::TRX_DECIMALS; +use mm2_number::bigdecimal::BigDecimal; +use mm2_number::BigInt; +use prost::Message; +use serde::{Deserialize, Serialize}; + +/// Per-contract TRON bandwidth overhead in bytes. +/// +/// Java-tron charges bandwidth as: +/// `tx.clearRet().getSerializedSize() + contract_count * MAX_RESULT_SIZE_IN_TX`, +/// with `MAX_RESULT_SIZE_IN_TX = 64`. We mirror that as +/// `encoded_len + 64 * contract_count` to avoid underestimating multi-contract txs. +/// +/// References: +/// - https://github.com/tronprotocol/java-tron/blob/develop/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java#L117-L128 +/// - https://github.com/tronprotocol/java-tron/blob/develop/common/src/main/java/org/tron/core/Constant.java#L41 +const RESULT_BYTES_OVERHEAD_PER_CONTRACT: u64 = 64; +/// TRON signatures are 65 bytes (`r || s || v`), used here as estimation placeholder. +const PLACEHOLDER_SIGNATURE_LEN: usize = 65; + +/// Fee breakdown for a TRON transaction. +/// +/// All monetary fields (`bandwidth_fee`, `energy_fee`, `total_fee`) are in TRX (not SUN). +/// Resource fields (`bandwidth_used`, `energy_used`) are in native units (bytes / energy units). +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct TronTxFeeDetails { + pub coin: String, + pub bandwidth_used: u64, + pub energy_used: u64, + pub bandwidth_fee: BigDecimal, + pub energy_fee: BigDecimal, + pub total_fee: BigDecimal, +} + +/// Subset of TRON's `AccountResourceMessage` (defined in `api.proto` of the +/// [tronprotocol/protocol](https://github.com/tronprotocol/protocol/blob/master/api/api.proto) +/// repo). The full message has 17 fields covering bandwidth, energy, storage, +/// and Tron Power; we only deserialize the 6 we need for fee estimation. +/// +/// Returned by the `/wallet/getaccountresource` HTTP endpoint as proto3 JSON. +/// Proto3 JSON keeps the original proto field names as-is, which is why the +/// casing is mixed (`freeNetUsed` vs `NetUsed` vs `EnergyUsed`). +/// +/// All fields use `#[serde(default)]` because proto3 JSON omits zero-value +/// fields. An empty `{}` response (unactivated account) deserializes to all +/// zeros. +/// +/// Values are raw units: +/// - bandwidth: bytes +/// - energy: energy units +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +pub struct TronAccountResources { + #[serde(default, rename = "freeNetUsed")] + pub free_net_used: u64, + #[serde(default, rename = "freeNetLimit")] + pub free_net_limit: u64, + #[serde(default, rename = "NetUsed")] + pub net_used: u64, + #[serde(default, rename = "NetLimit")] + pub net_limit: u64, + #[serde(default, rename = "EnergyUsed")] + pub energy_used: u64, + #[serde(default, rename = "EnergyLimit")] + pub energy_limit: u64, +} + +impl TronAccountResources { + /// Total bandwidth still available to the account: + /// `max(0, free_limit - free_used) + max(0, staked_limit - staked_used)`. + pub fn available_bandwidth(&self) -> u64 { + let free_bandwidth = self.free_net_limit.saturating_sub(self.free_net_used); + let staked_bandwidth = self.net_limit.saturating_sub(self.net_used); + free_bandwidth.saturating_add(staked_bandwidth) + } + + /// Energy still available to the account: `max(0, energy_limit - energy_used)`. + pub fn available_energy(&self) -> u64 { + self.energy_limit.saturating_sub(self.energy_used) + } +} + +/// Current chain prices (SUN per unit) from chain parameters. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TronChainPrices { + /// SUN per bandwidth byte (`getTransactionFee`). + pub bandwidth_price_sun: u64, + /// SUN per energy unit (`getEnergyFee`). + pub energy_price_sun: u64, +} + +/// Builds a transaction clone with a synthetic 65-byte signature. +/// +/// Bandwidth depends on full serialized transaction size, and signature bytes are +/// part of that size. For pre-sign estimation, we use a placeholder signature +/// matching TRON's real signature length. +pub fn tx_with_placeholder_signature(raw: &TransactionRaw) -> Transaction { + Transaction { + raw_data: Some(raw.clone()), + signature: vec![vec![0u8; PLACEHOLDER_SIGNATURE_LEN]], + } +} + +/// Estimates bandwidth bytes charged for this transaction. +/// +/// Formula: +/// `encoded_tx_size + RESULT_BYTES_OVERHEAD_PER_CONTRACT * contract_count` +/// +/// `contract_count` is clamped to at least 1 to keep estimation conservative when +/// tx metadata is missing. +pub fn estimate_bandwidth(tx: &Transaction) -> u64 { + let contract_count = tx + .raw_data + .as_ref() + .map(|raw| raw.contract.len().max(1) as u64) + .unwrap_or(1); + let tx_size = tx.encoded_len() as u64; + tx_size.saturating_add(RESULT_BYTES_OVERHEAD_PER_CONTRACT.saturating_mul(contract_count)) +} + +/// Estimates fee details for native TRX transfer (bandwidth-only path). +pub fn estimate_trx_transfer_fee( + tx: &Transaction, + resources: TronAccountResources, + prices: TronChainPrices, + fee_coin: &str, +) -> TronTxFeeDetails { + estimate_fee_details(tx, 0, resources, prices, fee_coin) +} + +/// Estimates fee details for TRC20 transfer (bandwidth + energy path). +/// +/// `energy_used` should come from `estimateenergy`/receipt-compatible estimation. +pub fn estimate_trc20_transfer_fee( + tx: &Transaction, + energy_used: u64, + resources: TronAccountResources, + prices: TronChainPrices, + fee_coin: &str, +) -> TronTxFeeDetails { + estimate_fee_details(tx, energy_used, resources, prices, fee_coin) +} + +/// Shared fee computation used by TRX/TRC20 paths. +/// +/// Steps: +/// 1. Estimate bandwidth usage from serialized tx size. +/// 2. Compute deficits against account resources. +/// 3. Price deficits using chain prices. +/// 4. Return fixed-scale TRX decimals. +fn estimate_fee_details( + tx: &Transaction, + energy_used: u64, + resources: TronAccountResources, + prices: TronChainPrices, + fee_coin: &str, +) -> TronTxFeeDetails { + let bandwidth_used = estimate_bandwidth(tx); + + let bandwidth_deficit = bandwidth_used.saturating_sub(resources.available_bandwidth()); + let energy_deficit = energy_used.saturating_sub(resources.available_energy()); + + let bandwidth_fee_sun = bandwidth_deficit.saturating_mul(prices.bandwidth_price_sun); + let energy_fee_sun = energy_deficit.saturating_mul(prices.energy_price_sun); + let total_fee_sun = bandwidth_fee_sun.saturating_add(energy_fee_sun); + + TronTxFeeDetails { + coin: fee_coin.to_owned(), + bandwidth_used, + energy_used, + bandwidth_fee: sun_to_trx_decimal(bandwidth_fee_sun), + energy_fee: sun_to_trx_decimal(energy_fee_sun), + total_fee: sun_to_trx_decimal(total_fee_sun), + } +} + +/// Converts SUN to TRX BigDecimal while preserving fixed 6-decimal scale. +fn sun_to_trx_decimal(sun: u64) -> BigDecimal { + BigDecimal::new(BigInt::from(sun), i64::from(TRX_DECIMALS)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::eth::tron::proto::{ContractType, TransactionContract, TYPE_URL_TRANSFER_CONTRACT}; + use crate::eth::tron::tx_builder::wrap_contract; + use common::cross_test; + + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::*; + + fn sample_raw() -> TransactionRaw { + TransactionRaw { + ref_block_bytes: vec![0x00, 0x01], + ref_block_hash: vec![0u8; 8], + expiration: 1_770_522_483_000, + data: Vec::new(), + contract: Vec::new(), + timestamp: 1_770_522_424_709, + fee_limit: 0, + } + } + + fn sample_contract() -> TransactionContract { + wrap_contract(ContractType::TransferContract, TYPE_URL_TRANSFER_CONTRACT, vec![1]) + } + + cross_test!(bandwidth_estimation_uses_encoded_tx_size_plus_result_buffer, { + let tx = tx_with_placeholder_signature(&sample_raw()); + let expected = tx.encoded_len() as u64 + RESULT_BYTES_OVERHEAD_PER_CONTRACT; + assert_eq!(estimate_bandwidth(&tx), expected); + }); + + cross_test!(bandwidth_estimation_scales_with_contract_count, { + let mut raw = sample_raw(); + raw.contract = vec![sample_contract(), sample_contract()]; + + let tx = tx_with_placeholder_signature(&raw); + let expected = tx.encoded_len() as u64 + RESULT_BYTES_OVERHEAD_PER_CONTRACT * 2; + assert_eq!(estimate_bandwidth(&tx), expected); + }); + + cross_test!( + bandwidth_estimation_defaults_to_single_contract_overhead_when_raw_is_missing, + { + let tx = Transaction { + raw_data: None, + signature: Vec::new(), + }; + + assert_eq!(estimate_bandwidth(&tx), RESULT_BYTES_OVERHEAD_PER_CONTRACT); + } + ); + + cross_test!(trx_fee_is_zero_when_bandwidth_is_fully_available, { + let tx = tx_with_placeholder_signature(&sample_raw()); + let bandwidth_used = estimate_bandwidth(&tx); + let resources = TronAccountResources { + free_net_used: 0, + free_net_limit: bandwidth_used, + net_used: 0, + net_limit: 0, + energy_used: 0, + energy_limit: 0, + }; + let prices = TronChainPrices { + bandwidth_price_sun: 1_000, + energy_price_sun: 420, + }; + + let details = estimate_trx_transfer_fee(&tx, resources, prices, "TRX"); + assert_eq!(details.coin, "TRX"); + assert_eq!(details.energy_used, 0); + assert_eq!(details.bandwidth_fee, BigDecimal::from(0)); + assert_eq!(details.energy_fee, BigDecimal::from(0)); + assert_eq!(details.total_fee, BigDecimal::from(0)); + }); + + cross_test!(trc20_fee_calculation_handles_bandwidth_and_energy_deficits, { + let tx = tx_with_placeholder_signature(&sample_raw()); + let bandwidth_used = estimate_bandwidth(&tx); + let resources = TronAccountResources { + free_net_used: 100, + free_net_limit: 100, // no free bandwidth left + net_used: 30, + net_limit: 80, // 50 bandwidth left + energy_used: 200, + energy_limit: 300, // 100 energy left + }; + let prices = TronChainPrices { + bandwidth_price_sun: 1_000, + energy_price_sun: 420, + }; + let energy_used = 500u64; + + let details = estimate_trc20_transfer_fee(&tx, energy_used, resources, prices, "TRX"); + + let bandwidth_deficit = bandwidth_used.saturating_sub(50); + let expected_bw_fee_sun = bandwidth_deficit * 1_000; + let expected_energy_fee_sun = 400 * 420; + let expected_total_fee_sun = expected_bw_fee_sun + expected_energy_fee_sun; + + assert_eq!(details.bandwidth_used, bandwidth_used); + assert_eq!(details.energy_used, energy_used); + assert_eq!(details.bandwidth_fee, sun_to_trx_decimal(expected_bw_fee_sun)); + assert_eq!(details.energy_fee, sun_to_trx_decimal(expected_energy_fee_sun)); + assert_eq!(details.total_fee, sun_to_trx_decimal(expected_total_fee_sun)); + }); + + cross_test!(fee_calculation_saturates_on_large_inputs, { + let tx = tx_with_placeholder_signature(&sample_raw()); + let resources = TronAccountResources::default(); + let prices = TronChainPrices { + bandwidth_price_sun: u64::MAX, + energy_price_sun: u64::MAX, + }; + + let details = estimate_trc20_transfer_fee(&tx, u64::MAX, resources, prices, "TRX"); + assert_eq!(details.bandwidth_fee, sun_to_trx_decimal(u64::MAX)); + assert_eq!(details.energy_fee, sun_to_trx_decimal(u64::MAX)); + assert_eq!(details.total_fee, sun_to_trx_decimal(u64::MAX)); + }); + + // Verifies that `sun_to_trx_decimal` preserves a fixed 6-digit scale in + // the internal `BigDecimal` representation. This guards against replacing + // `BigDecimal::new` with division, which would normalize whole-number + // results (e.g. 1 TRX becomes `(1, 0)` instead of `(1_000_000, 6)`), + // breaking consistent serialization. + cross_test!(sun_to_trx_decimal_uses_fixed_six_decimal_scale, { + let one_sun = sun_to_trx_decimal(1); + let one_trx = sun_to_trx_decimal(1_000_000); + + let (one_sun_int, one_sun_scale) = one_sun.as_bigint_and_exponent(); + let (one_trx_int, one_trx_scale) = one_trx.as_bigint_and_exponent(); + + assert_eq!(one_sun_int, BigInt::from(1)); + assert_eq!(one_sun_scale, i64::from(TRX_DECIMALS)); + + // Whole TRX value must still be stored as (1_000_000, 6), not normalized to (1, 0). + assert_eq!(one_trx_int, BigInt::from(1_000_000u64)); + assert_eq!(one_trx_scale, i64::from(TRX_DECIMALS)); + }); +} diff --git a/mm2src/coins/eth/tron/proto.rs b/mm2src/coins/eth/tron/proto.rs new file mode 100644 index 0000000000..20504b871a --- /dev/null +++ b/mm2src/coins/eth/tron/proto.rs @@ -0,0 +1,452 @@ +//! Minimal TRON transaction protobuf types. +//! +//! Hand-written `prost::Message` structs matching TRON's `Tron.proto`, +//! `balance_contract.proto`, and `smart_contract.proto` definitions. +//! Only the types needed for TRX transfers and TRC20 interactions are included. +//! +//! Field tags are non-sequential in several messages (notably `TransactionRaw`) +//! and must match the upstream TRON protocol exactly β€” signatures and transaction +//! IDs are computed over the raw protobuf bytes. + +/// Type URL for `TransferContract` (native TRX transfer). +pub const TYPE_URL_TRANSFER_CONTRACT: &str = "type.googleapis.com/protocol.TransferContract"; + +/// Type URL for `TriggerSmartContract` (TRC20 / smart contract calls). +pub const TYPE_URL_TRIGGER_SMART_CONTRACT: &str = "type.googleapis.com/protocol.TriggerSmartContract"; + +/// Equivalent of `google.protobuf.Any`. +#[derive(Clone, PartialEq, Eq, prost::Message)] +pub struct Any { + #[prost(string, tag = "1")] + pub type_url: prost::alloc::string::String, + #[prost(bytes = "vec", tag = "2")] + pub value: prost::alloc::vec::Vec, +} + +/// `protocol.TransferContract` β€” native TRX transfer. +/// +/// Address fields are raw 21-byte TRON addresses (`0x41` prefix + 20-byte EVM address). +#[derive(Clone, PartialEq, Eq, prost::Message)] +pub struct TransferContract { + #[prost(bytes = "vec", tag = "1")] + pub owner_address: prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "2")] + pub to_address: prost::alloc::vec::Vec, + /// Amount in SUN (1 TRX = 1,000,000 SUN). Must be non-negative. + #[prost(int64, tag = "3")] + pub amount: i64, +} + +/// `protocol.TriggerSmartContract` β€” smart contract invocation (TRC20 transfers, etc.). +/// +/// Address fields are raw 21-byte TRON addresses (`0x41` prefix + 20-byte EVM address). +#[derive(Clone, PartialEq, Eq, prost::Message)] +pub struct TriggerSmartContract { + #[prost(bytes = "vec", tag = "1")] + pub owner_address: prost::alloc::vec::Vec, + #[prost(bytes = "vec", tag = "2")] + pub contract_address: prost::alloc::vec::Vec, + /// TRX amount sent with the call, in SUN. Must be non-negative. + #[prost(int64, tag = "3")] + pub call_value: i64, + /// ABI-encoded function call data. + #[prost(bytes = "vec", tag = "4")] + pub data: prost::alloc::vec::Vec, + /// TRC10 token value sent with the call. Must be non-negative. + #[prost(int64, tag = "5")] + pub call_token_value: i64, + /// TRC10 token ID. Must be non-negative. + #[prost(int64, tag = "6")] + pub token_id: i64, +} + +/// Contract types used in `TransactionContract.type`. +/// +/// The `Unspecified` variant (value 0) is required by proto3 convention and ensures +/// prost correctly encodes non-zero variants on the wire (prost skips encoding the +/// first variant as the "default"). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, prost::Enumeration)] +#[repr(i32)] +pub enum ContractType { + Unspecified = 0, + TransferContract = 1, + TriggerSmartContract = 31, +} + +/// `Transaction.Contract` β€” wraps a typed contract inside a transaction. +#[derive(Clone, PartialEq, Eq, prost::Message)] +pub struct TransactionContract { + /// `ContractType` discriminant. + #[prost(enumeration = "ContractType", tag = "1")] + pub r#type: i32, + /// The contract body serialized as `google.protobuf.Any`. + #[prost(message, optional, tag = "2")] + pub parameter: Option, + #[prost(int32, tag = "5")] + pub permission_id: i32, +} + +/// `Transaction.raw` β€” the unsigned transaction payload. +/// +/// **Tags are non-sequential** (1, 4, 8, 10, 11, 14, 18) matching `Tron.proto`. +/// Deprecated fields (`ref_block_num`=3, `auths`=9, `scripts`=12) are intentionally omitted. +#[derive(Clone, PartialEq, Eq, prost::Message)] +pub struct TransactionRaw { + /// Last 2 bytes of the block number (TAPOS). + #[prost(bytes = "vec", tag = "1")] + pub ref_block_bytes: prost::alloc::vec::Vec, + /// Bytes 8..16 of the block ID (TAPOS). + #[prost(bytes = "vec", tag = "4")] + pub ref_block_hash: prost::alloc::vec::Vec, + /// Transaction expiration time in milliseconds since Unix epoch. + #[prost(int64, tag = "8")] + pub expiration: i64, + /// Optional memo/data field. + #[prost(bytes = "vec", tag = "10")] + pub data: prost::alloc::vec::Vec, + /// One or more contracts (typically exactly one). + #[prost(message, repeated, tag = "11")] + pub contract: prost::alloc::vec::Vec, + /// Transaction creation time in milliseconds since Unix epoch. + #[prost(int64, tag = "14")] + pub timestamp: i64, + /// Maximum TRX (in SUN) willing to spend on energy. 0 for native TRX transfers. + /// Must be non-negative. + #[prost(int64, tag = "18")] + pub fee_limit: i64, +} + +/// `protocol.Transaction` β€” a complete (optionally signed) TRON transaction. +#[derive(Clone, PartialEq, Eq, prost::Message)] +pub struct Transaction { + #[prost(message, optional, tag = "1")] + pub raw_data: Option, + /// Each entry is a 65-byte signature: `r(32) || s(32) || v(1)`. + #[prost(bytes = "vec", repeated, tag = "2")] + pub signature: prost::alloc::vec::Vec>, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::eth::tron::tx_builder::wrap_contract; + use common::cross_test; + use prost::Message; + + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::*; + + /// Dummy 21-byte TRON address (0x41 prefix + 20 bytes filled with `fill`). + fn dummy_tron_address(fill: u8) -> Vec { + let mut addr = vec![0x41]; + addr.extend_from_slice(&[fill; 20]); + addr + } + + // ----------------------------------------------------------------------- + // Roundtrip encode/decode tests + // ----------------------------------------------------------------------- + + cross_test!(any_roundtrip, { + let original = Any { + type_url: TYPE_URL_TRANSFER_CONTRACT.to_string(), + value: vec![1, 2, 3, 4], + }; + let bytes = original.encode_to_vec(); + let decoded = Any::decode(bytes.as_slice()).unwrap(); + assert_eq!(original, decoded); + }); + + cross_test!(transfer_contract_roundtrip, { + let original = TransferContract { + owner_address: dummy_tron_address(0xAA), + to_address: dummy_tron_address(0xBB), + amount: 1_000_000, // 1 TRX + }; + let bytes = original.encode_to_vec(); + let decoded = TransferContract::decode(bytes.as_slice()).unwrap(); + assert_eq!(original, decoded); + }); + + cross_test!(trigger_smart_contract_roundtrip, { + let original = TriggerSmartContract { + owner_address: dummy_tron_address(0xAA), + contract_address: dummy_tron_address(0xCC), + call_value: 0, + data: vec![0xa9, 0x05, 0x9c, 0xbb, 0x00, 0x01, 0x02, 0x03], + call_token_value: 0, + token_id: 0, + }; + let bytes = original.encode_to_vec(); + let decoded = TriggerSmartContract::decode(bytes.as_slice()).unwrap(); + assert_eq!(original, decoded); + }); + + cross_test!(transaction_contract_with_nested_any_roundtrip, { + let transfer = TransferContract { + owner_address: dummy_tron_address(0xAA), + to_address: dummy_tron_address(0xBB), + amount: 5_000_000, + }; + let any = Any { + type_url: TYPE_URL_TRANSFER_CONTRACT.to_string(), + value: transfer.encode_to_vec(), + }; + let original = TransactionContract { + r#type: ContractType::TransferContract as i32, + parameter: Some(any), + permission_id: 0, + }; + let bytes = original.encode_to_vec(); + let decoded = TransactionContract::decode(bytes.as_slice()).unwrap(); + assert_eq!(original, decoded); + + // Also verify the nested Any.value decodes back to the original TransferContract. + let inner = TransferContract::decode(decoded.parameter.unwrap().value.as_slice()).unwrap(); + assert_eq!(inner.amount, 5_000_000); + }); + + cross_test!(transaction_raw_non_sequential_tags_roundtrip, { + let transfer = TransferContract { + owner_address: dummy_tron_address(0xAA), + to_address: dummy_tron_address(0xBB), + amount: 10_000_000, + }; + let contract = wrap_contract( + ContractType::TransferContract, + TYPE_URL_TRANSFER_CONTRACT, + transfer.encode_to_vec(), + ); + + let original = TransactionRaw { + ref_block_bytes: vec![0x12, 0x34], + ref_block_hash: vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11, 0x22], + expiration: 1_700_000_060_000, + data: Vec::new(), + contract: vec![contract], + timestamp: 1_700_000_000_000, + fee_limit: 0, + }; + let bytes = original.encode_to_vec(); + let decoded = TransactionRaw::decode(bytes.as_slice()).unwrap(); + assert_eq!(original, decoded); + }); + + cross_test!(full_transaction_roundtrip, { + let transfer = TransferContract { + owner_address: dummy_tron_address(0xAA), + to_address: dummy_tron_address(0xBB), + amount: 1_000_000, + }; + let contract = wrap_contract( + ContractType::TransferContract, + TYPE_URL_TRANSFER_CONTRACT, + transfer.encode_to_vec(), + ); + let raw = TransactionRaw { + ref_block_bytes: vec![0x56, 0x78], + ref_block_hash: vec![0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08], + expiration: 1_700_000_060_000, + data: Vec::new(), + contract: vec![contract], + timestamp: 1_700_000_000_000, + fee_limit: 0, + }; + + // 65-byte placeholder signature (r || s || v). + let fake_sig = vec![0xFFu8; 65]; + + let original = Transaction { + raw_data: Some(raw), + signature: vec![fake_sig], + }; + let bytes = original.encode_to_vec(); + let decoded = Transaction::decode(bytes.as_slice()).unwrap(); + assert_eq!(original, decoded); + }); + + cross_test!(trigger_smart_contract_transaction_roundtrip, { + let trigger = TriggerSmartContract { + owner_address: dummy_tron_address(0xAA), + contract_address: dummy_tron_address(0xCC), + call_value: 0, + // Simulated transfer(address,uint256) ABI call: 4-byte selector + 64 bytes params. + data: { + let mut d = vec![0xa9, 0x05, 0x9c, 0xbb]; + d.extend_from_slice(&[0x00; 64]); + d + }, + call_token_value: 0, + token_id: 0, + }; + let contract = wrap_contract( + ContractType::TriggerSmartContract, + TYPE_URL_TRIGGER_SMART_CONTRACT, + trigger.encode_to_vec(), + ); + let raw = TransactionRaw { + ref_block_bytes: vec![0xAB, 0xCD], + ref_block_hash: vec![0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80], + expiration: 1_700_000_060_000, + data: Vec::new(), + contract: vec![contract], + timestamp: 1_700_000_000_000, + fee_limit: 100_000_000, // 100 TRX fee limit for energy. + }; + let fake_sig = vec![0xAAu8; 65]; + let original = Transaction { + raw_data: Some(raw), + signature: vec![fake_sig], + }; + let bytes = original.encode_to_vec(); + let decoded = Transaction::decode(bytes.as_slice()).unwrap(); + assert_eq!(original, decoded); + }); + + cross_test!(contract_type_values, { + assert_eq!(ContractType::TransferContract as i32, 1); + assert_eq!(ContractType::TriggerSmartContract as i32, 31); + }); + + cross_test!(default_transaction_raw_has_zero_fee_limit, { + let raw = TransactionRaw::default(); + assert_eq!(raw.fee_limit, 0); + assert_eq!(raw.expiration, 0); + assert_eq!(raw.timestamp, 0); + assert!(raw.contract.is_empty()); + assert!(raw.ref_block_bytes.is_empty()); + assert!(raw.ref_block_hash.is_empty()); + }); + + // ----------------------------------------------------------------------- + // Golden vector tests β€” real TRON transactions from developer docs. + // These validate wire-format compatibility against known-good raw_data_hex + // produced by TRON nodes, ensuring field tags and types are correct. + // ----------------------------------------------------------------------- + + // Golden vector: TransferContract (native TRX transfer). + // Source: https://developers.tron.network/docs/tron-protocol-transaction + cross_test!(golden_vector_transfer_contract, { + let raw_data_hex = concat!( + "0a020add", // ref_block_bytes: 0add + "22086c2763abadf9ed29", // ref_block_hash: 6c2763abadf9ed29 + "40c8d5deea822e", // expiration: 1581308685000 + "5a65", // contract (length-delimited) + "0801", // type: 1 (TransferContract) + "1261", // parameter (Any, length-delimited) + "0a2d", // type_url (length-delimited) + "747970652e676f6f676c65617069732e636f6d", // "type.googleapis.com" + "2f70726f746f636f6c2e", // "/protocol." + "5472616e73666572436f6e7472616374", // "TransferContract" + "1230", // value (length-delimited) + "0a15", // owner_address + "418840e6c55b9ada326d211d818c34a994aeced808", + "1215", // to_address + "41d3136787e667d1e055d2cd5db4b5f6c880563049", + "1864", // amount: 100 + "70ac89dbea822e", // timestamp: 1581308626092 + ); + let raw_bytes = hex::decode(raw_data_hex).unwrap(); + + // Decode and verify every field. + let raw = TransactionRaw::decode(raw_bytes.as_slice()).unwrap(); + assert_eq!(hex::encode(&raw.ref_block_bytes), "0add"); + assert_eq!(hex::encode(&raw.ref_block_hash), "6c2763abadf9ed29"); + assert_eq!(raw.expiration, 1_581_308_685_000); + assert_eq!(raw.timestamp, 1_581_308_626_092); + assert_eq!(raw.fee_limit, 0); + assert!(raw.data.is_empty()); + assert_eq!(raw.contract.len(), 1); + + let contract = &raw.contract[0]; + assert_eq!(contract.r#type, ContractType::TransferContract as i32); + assert_eq!(contract.permission_id, 0); + + let any = contract.parameter.as_ref().unwrap(); + assert_eq!(any.type_url, TYPE_URL_TRANSFER_CONTRACT); + + let transfer = TransferContract::decode(any.value.as_slice()).unwrap(); + assert_eq!( + hex::encode(&transfer.owner_address), + "418840e6c55b9ada326d211d818c34a994aeced808" + ); + assert_eq!( + hex::encode(&transfer.to_address), + "41d3136787e667d1e055d2cd5db4b5f6c880563049" + ); + assert_eq!(transfer.amount, 100); + + // Re-encode must produce identical bytes (canonical protobuf encoding). + assert_eq!(raw.encode_to_vec(), raw_bytes); + }); + + // Golden vector: TriggerSmartContract (TRC20 token transfer). + // Source: https://developers.tron.network/docs/smart-contract-deployment-and-invocation + cross_test!(golden_vector_trigger_smart_contract, { + let raw_data_hex = concat!( + "0a021c51", // ref_block_bytes: 1c51 + "220874912b480b7b887c", // ref_block_hash: 74912b480b7b887c + "40c8d2e7e78a30", // expiration: 1652169501000 + "5aae01", // contract (length-delimited) + "081f", // type: 31 (TriggerSmartContract) + "12a901", // parameter (Any, length-delimited) + "0a31", // type_url (length-delimited) + "747970652e676f6f676c65617069732e636f6d", // "type.googleapis.com" + "2f70726f746f636f6c2e", // "/protocol." + "54726967676572536d617274436f6e7472616374", // "TriggerSmartContract" + "1274", // value (length-delimited) + "0a15", // owner_address + "41977c20977f412c2a1aa4ef3d49fee5ec4c31cdfb", + "1215", // contract_address + "419e62be7f4f103c36507cb2a753418791b1cdc182", + "2244", // data (68 bytes) + "a9059cbb", // selector: transfer(address,uint256) + "00000000000000000000004115208ef33a926919ed270e2fa61367b2da3753da", + "0000000000000000000000000000000000000000000000000000000000000032", + "70b286e4e78a30", // timestamp: 1652169442098 + "900180c2d72f", // fee_limit: 100000000 + ); + let raw_bytes = hex::decode(raw_data_hex).unwrap(); + + // Decode and verify every field. + let raw = TransactionRaw::decode(raw_bytes.as_slice()).unwrap(); + assert_eq!(hex::encode(&raw.ref_block_bytes), "1c51"); + assert_eq!(hex::encode(&raw.ref_block_hash), "74912b480b7b887c"); + assert_eq!(raw.expiration, 1_652_169_501_000); + assert_eq!(raw.timestamp, 1_652_169_442_098); + assert_eq!(raw.fee_limit, 100_000_000); + assert!(raw.data.is_empty()); + assert_eq!(raw.contract.len(), 1); + + let contract = &raw.contract[0]; + assert_eq!(contract.r#type, ContractType::TriggerSmartContract as i32); + assert_eq!(contract.permission_id, 0); + + let any = contract.parameter.as_ref().unwrap(); + assert_eq!(any.type_url, TYPE_URL_TRIGGER_SMART_CONTRACT); + + let trigger = TriggerSmartContract::decode(any.value.as_slice()).unwrap(); + assert_eq!( + hex::encode(&trigger.owner_address), + "41977c20977f412c2a1aa4ef3d49fee5ec4c31cdfb" + ); + assert_eq!( + hex::encode(&trigger.contract_address), + "419e62be7f4f103c36507cb2a753418791b1cdc182" + ); + assert_eq!(trigger.call_value, 0); + assert_eq!(trigger.call_token_value, 0); + assert_eq!(trigger.token_id, 0); + + // Verify ABI-encoded data: transfer(address,uint256). + let expected_data = concat!( + "a9059cbb", // selector + "00000000000000000000004115208ef33a926919ed270e2fa61367b2da3753da", // address param + "0000000000000000000000000000000000000000000000000000000000000032", // uint256 = 50 + ); + assert_eq!(hex::encode(&trigger.data), expected_data); + + // Re-encode must produce identical bytes (canonical protobuf encoding). + assert_eq!(raw.encode_to_vec(), raw_bytes); + }); +} diff --git a/mm2src/coins/eth/tron/sign.rs b/mm2src/coins/eth/tron/sign.rs new file mode 100644 index 0000000000..0bdc62ffd5 --- /dev/null +++ b/mm2src/coins/eth/tron/sign.rs @@ -0,0 +1,142 @@ +//! TRON transaction signing utilities. +//! +//! TRON signs the SHA256 hash of `TransactionRaw` protobuf bytes and stores +//! signatures as `r(32) || s(32) || v(1)` where `v` must be 0 or 1. +//! + +use super::proto::{Transaction, TransactionRaw}; +use bitcrypto::sha256; +use derive_more::Display; +use ethereum_types::H256; +use ethkey::{sign, Secret}; +use prost::Message; + +#[derive(Debug, Display)] +pub enum TronSignError { + #[display(fmt = "Invalid signature length: {}", _0)] + InvalidSignatureLength(usize), + #[display(fmt = "Invalid recovery id: {}", _0)] + InvalidRecoveryId(u8), + #[display(fmt = "Signing failed: {}", _0)] + SigningFailed(String), +} + +impl From for TronSignError { + fn from(err: ethkey::Error) -> Self { + TronSignError::SigningFailed(err.to_string()) + } +} + +/// Normalize recovery id to TRON format (`0/1`). +/// +/// Some signers expose Ethereum legacy values (`27/28`), so we accept both and +/// convert to `0/1`. Any other value is rejected as invalid. +fn normalize_tron_v(v: u8) -> Result { + match v { + 0 | 1 => Ok(v), + 27 | 28 => Ok(v - 27), + invalid => Err(TronSignError::InvalidRecoveryId(invalid)), + } +} + +/// Compute TRON txid as SHA-256 over protobuf-encoded `TransactionRaw` bytes. +fn tron_tx_id_from_raw(raw: &TransactionRaw) -> H256 { + let raw_bytes = raw.encode_to_vec(); + H256::from(sha256(&raw_bytes).take()) +} + +/// Sign `TransactionRaw` with secp256k1 and return `(tx_id, signed_transaction)`. +/// +/// TRON stores signatures in `r(32) || s(32) || v(1)` format where `v` must be `0/1`. +/// We normalize legacy Ethereum-style `v` values (`27/28`) to `0/1` for compatibility. +pub fn sign_tron_transaction(raw: &TransactionRaw, secret: &Secret) -> Result<(H256, Transaction), TronSignError> { + let tx_id = tron_tx_id_from_raw(raw); + let signature = sign(secret, &tx_id)?; + let mut signature_bytes = signature.to_vec(); + if signature_bytes.len() != 65 { + return Err(TronSignError::InvalidSignatureLength(signature_bytes.len())); + } + + signature_bytes[64] = normalize_tron_v(signature_bytes[64])?; + + let signed = Transaction { + raw_data: Some(raw.clone()), + signature: vec![signature_bytes], + }; + + Ok((tx_id, signed)) +} + +#[cfg(test)] +mod tests { + use super::*; + use common::cross_test; + use ethereum_types::H256; + use ethkey::{public_to_address, verify_address, KeyPair, Signature}; + use prost::Message; + use std::str::FromStr; + + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::*; + + /// Golden TRON TransferContract `raw_data_hex` fixture from TRON developer docs: + /// https://developers.tron.network/docs/tron-protocol-transaction + /// + /// This same vector is also validated in `tron/proto.rs` golden tests. + const GOLDEN_TRANSFER_RAW_DATA_HEX: &str = concat!( + "0a020add", + "22086c2763abadf9ed29", + "40c8d5deea822e", + "5a65", + "0801", + "1261", + "0a2d", + "747970652e676f6f676c65617069732e636f6d", + "2f70726f746f636f6c2e", + "5472616e73666572436f6e7472616374", + "1230", + "0a15", + "418840e6c55b9ada326d211d818c34a994aeced808", + "1215", + "41d3136787e667d1e055d2cd5db4b5f6c880563049", + "1864", + "70ac89dbea822e", + ); + + /// Expected txid for `GOLDEN_TRANSFER_RAW_DATA_HEX`: + /// SHA256(raw_data_bytes) = 9f62a65d0616c749643c4e2620b7877efd0f04dd5b2b4cd14004570d39858d7e + const GOLDEN_TRANSFER_TXID_HEX: &str = "9f62a65d0616c749643c4e2620b7877efd0f04dd5b2b4cd14004570d39858d7e"; + + fn decode_golden_transfer_raw() -> TransactionRaw { + let raw_bytes = hex::decode(GOLDEN_TRANSFER_RAW_DATA_HEX).unwrap(); + TransactionRaw::decode(raw_bytes.as_slice()).unwrap() + } + + cross_test!( + test_sign_tron_transaction_verifies_signer_and_rejects_tampered_digest, + { + let raw = decode_golden_transfer_raw(); + let key_pair = KeyPair::from_secret_slice(&[1u8; 32]).expect("valid test key pair"); + let expected_address = public_to_address(key_pair.public()); + + let (tx_id, signed) = sign_tron_transaction(&raw, key_pair.secret()).unwrap(); + assert_eq!(tx_id, tron_tx_id_from_raw(&raw)); + assert_eq!(tx_id, H256::from_slice(&hex::decode(GOLDEN_TRANSFER_TXID_HEX).unwrap())); + + assert_eq!(signed.raw_data, Some(raw.clone())); + assert_eq!(signed.signature.len(), 1); + assert_eq!(signed.signature[0].len(), 65); + assert!(signed.signature[0][64] <= 1); + + let signature = Signature::from_str(&hex::encode(&signed.signature[0])).expect("valid signature hex"); + assert!(verify_address(&expected_address, &signature, &tx_id).expect("verification should execute")); + + let mut tampered_raw = raw.clone(); + tampered_raw.timestamp += 1; + let tampered_tx_id = tron_tx_id_from_raw(&tampered_raw); + assert!( + !verify_address(&expected_address, &signature, &tampered_tx_id).expect("verification should execute") + ); + } + ); +} diff --git a/mm2src/coins/eth/tron/tx_builder.rs b/mm2src/coins/eth/tron/tx_builder.rs new file mode 100644 index 0000000000..9954e90299 --- /dev/null +++ b/mm2src/coins/eth/tron/tx_builder.rs @@ -0,0 +1,202 @@ +//! TRON unsigned transaction builder. +//! +//! Constructs `TransactionRaw` protobuf messages for TRX transfers and TRC20 +//! token transfers. Signing is handled separately (see `sign` module). + +use super::proto::{ + Any, ContractType, TransactionContract, TransactionRaw, TransferContract, TriggerSmartContract, + TYPE_URL_TRANSFER_CONTRACT, TYPE_URL_TRIGGER_SMART_CONTRACT, +}; +use super::{trc20_transfer_tokens, TaposBlockData, TronAddress}; +use crate::eth::ERC20_CONTRACT; +use ethereum_types::U256; +use prost::Message; + +/// Default transaction expiration window (60 seconds from block timestamp). +const DEFAULT_TX_EXPIRATION_MS: i64 = 60_000; + +/// Extract TAPOS reference fields from a recent block. +/// +/// Returns `(ref_block_bytes, ref_block_hash)` for `TransactionRaw`: +/// - `ref_block_bytes`: last 2 bytes of block number (big-endian) +/// - `ref_block_hash`: bytes 8..16 of blockID +fn tapos_from_block(block_number: u64, block_id: &[u8; 32]) -> (Vec, Vec) { + let n = block_number.to_be_bytes(); + let ref_block_bytes = n[6..8].to_vec(); + let ref_block_hash = block_id[8..16].to_vec(); + (ref_block_bytes, ref_block_hash) +} + +/// Convert a `TronAddress` to raw 21-byte protobuf format (`0x41` prefix + 20 bytes). +pub fn tron_addr_bytes(addr: &TronAddress) -> Vec { + addr.as_bytes().to_vec() +} + +/// Wrap a protobuf-encoded contract into a `TransactionContract` with `permission_id: 0`. +pub(super) fn wrap_contract(contract_type: ContractType, type_url: &str, value: Vec) -> TransactionContract { + TransactionContract { + r#type: contract_type as i32, + parameter: Some(Any { + type_url: type_url.to_string(), + value, + }), + permission_id: 0, + } +} + +/// Build an unsigned TRX (native) transfer transaction. +/// +/// Timestamp and expiration are derived from `block_data`: +/// - `timestamp` = block timestamp (not validated by java-tron; matches TronWeb) +/// - `expiration` = block timestamp + `expiration_sec` (converted to ms) +pub fn build_trx_transfer( + from: &TronAddress, + to: &TronAddress, + amount_sun: i64, + block_data: &TaposBlockData, + expiration_seconds: Option, +) -> TransactionRaw { + let (ref_block_bytes, ref_block_hash) = tapos_from_block(block_data.number, &block_data.block_id); + let expiration_ms = expiration_seconds + .map(|s| (s as i64).saturating_mul(1000)) + .unwrap_or(DEFAULT_TX_EXPIRATION_MS); + + let transfer = TransferContract { + owner_address: tron_addr_bytes(from), + to_address: tron_addr_bytes(to), + amount: amount_sun, + }; + let contract = wrap_contract( + ContractType::TransferContract, + TYPE_URL_TRANSFER_CONTRACT, + transfer.encode_to_vec(), + ); + + TransactionRaw { + ref_block_bytes, + ref_block_hash, + expiration: block_data.timestamp.saturating_add(expiration_ms), + data: Vec::new(), + contract: vec![contract], + timestamp: block_data.timestamp, + fee_limit: 0, + } +} + +/// Build an unsigned TRC20 `transfer(address,uint256)` transaction. +/// +/// Timestamp and expiration are derived from `block_data` (same policy as TRX transfers). +pub fn build_trc20_transfer( + from: &TronAddress, + contract_addr: &TronAddress, + recipient: &TronAddress, + amount: U256, + fee_limit: i64, + block_data: &TaposBlockData, + expiration_seconds: Option, +) -> Result { + let (ref_block_bytes, ref_block_hash) = tapos_from_block(block_data.number, &block_data.block_id); + let expiration_ms = expiration_seconds + .map(|s| (s as i64).saturating_mul(1000)) + .unwrap_or(DEFAULT_TX_EXPIRATION_MS); + + let trigger = TriggerSmartContract { + owner_address: tron_addr_bytes(from), + contract_address: tron_addr_bytes(contract_addr), + call_value: 0, + data: abi_encode_trc20_transfer(recipient, amount)?, + call_token_value: 0, + token_id: 0, + }; + let contract = wrap_contract( + ContractType::TriggerSmartContract, + TYPE_URL_TRIGGER_SMART_CONTRACT, + trigger.encode_to_vec(), + ); + + Ok(TransactionRaw { + ref_block_bytes, + ref_block_hash, + expiration: block_data.timestamp.saturating_add(expiration_ms), + data: Vec::new(), + contract: vec![contract], + timestamp: block_data.timestamp, + fee_limit, + }) +} + +/// ABI-encode `transfer(address,uint256)` call data using the shared ERC20 ABI. +/// +/// Uses the same `ERC20_CONTRACT` as the EVM path. The recipient is converted +/// to a 20-byte EVM address (0x41 prefix stripped) for standard ABI encoding. +fn abi_encode_trc20_transfer(recipient: &TronAddress, amount: U256) -> Result, ethabi::Error> { + let function = ERC20_CONTRACT.function("transfer")?; + let tokens = trc20_transfer_tokens(recipient, amount); + function.encode_input(&tokens) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::eth::tron::test_fixtures::{nile_block_64687673, TEST_FROM_HEX, TEST_TO_HEX}; + use common::cross_test; + use prost::Message; + + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::*; + + // Golden vector: verify builder output matches a real broadcast Nile TRX transfer. + // Source: https://nile.tronscan.org/#/transaction/ebd91b4138365e7d8d71d9ce3704f3889614e7316c20ab449011fe4dbc67f0a4 + cross_test!(build_trx_transfer_golden_vector, { + // Real Nile tx: 1000 SUN from 4123b0...08b6 to 418840...d808 + // TAPOS source: block 64687673 (blockID: 0000000003db0e39901ce5715271b601...) + let block_data = nile_block_64687673(); + let from = TronAddress::from_hex(TEST_FROM_HEX).unwrap(); + let to = TronAddress::from_hex(TEST_TO_HEX).unwrap(); + + let mut raw = build_trx_transfer(&from, &to, 1000, &block_data, None); + // Verify timestamp/expiration derived from block_data + assert_eq!(raw.timestamp, block_data.timestamp); + assert_eq!(raw.expiration, block_data.timestamp + DEFAULT_TX_EXPIRATION_MS); + // Override to match the real broadcast tx values for golden vector comparison. + raw.timestamp = 1_770_522_424_709; + raw.expiration = 1_770_522_483_000; + + let expected_hex = "0a020e392208901ce5715271b60140b8b2f4dac3335a66080112620a2d747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e73666572436f6e747261637412310a154123b00d15c601b30613bf5a3b2f72527c79cc08b61215418840e6c55b9ada326d211d818c34a994aeced80818e8077085ebf0dac333"; + assert_eq!(hex::encode(raw.encode_to_vec()), expected_hex); + }); + + // Golden vector: verify builder output matches a real broadcast Nile TRC20 transfer. + // Source: https://nile.tronscan.org/#/transaction/f0cd35cfdafa93c67c3ee652df3d8995f1eed42814f6a225c6d767e280db3444 + cross_test!(build_trc20_transfer_golden_vector, { + // Real Nile tx: TRC20 transfer of 2,380,000 units + // TAPOS source: block 64837309 (blockID: 0000000003dd56bde31bf1375e25873d...) + let block_data = TaposBlockData { + number: 64_837_309, + block_id: { + let bytes = hex::decode("0000000003dd56bde31bf1375e25873dd2d6dea05d81e126be272f42e4c27c26").unwrap(); + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + arr + }, + timestamp: 1_770_972_777_000, + }; + let from = TronAddress::from_hex("413c5568f418ee30c71f61813a23ef1f92fb1c434c").unwrap(); + let contract_addr = TronAddress::from_hex("41eca9bc828a3005b9a3b909f2cc5c2a54794de05f").unwrap(); + let recipient = TronAddress::from_hex("413ed853b5cddf63533c4e6703b27feb34ff9063b3").unwrap(); + let amount = U256::from(2_380_000u64); + let fee_limit = 2_172_000i64; + + let mut raw = + build_trc20_transfer(&from, &contract_addr, &recipient, amount, fee_limit, &block_data, None).unwrap(); + // Verify timestamp/expiration derived from block_data + assert_eq!(raw.timestamp, block_data.timestamp); + assert_eq!(raw.expiration, block_data.timestamp + DEFAULT_TX_EXPIRATION_MS); + // Override to match the real broadcast tx values for golden vector comparison. + raw.timestamp = 1_770_972_831_784; + raw.expiration = 1_770_972_891_000; + + let expected_hex = "0a0256bd2208e31bf1375e25873d40f88ed7b1c5335aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a15413c5568f418ee30c71f61813a23ef1f92fb1c434c121541eca9bc828a3005b9a3b909f2cc5c2a54794de05f2244a9059cbb0000000000000000000000003ed853b5cddf63533c4e6703b27feb34ff9063b300000000000000000000000000000000000000000000000000000000002450e070a8c0d3b1c5339001e0c88401"; + assert_eq!(hex::encode(raw.encode_to_vec()), expected_hex); + }); +} diff --git a/mm2src/coins/eth/tron/withdraw.rs b/mm2src/coins/eth/tron/withdraw.rs new file mode 100644 index 0000000000..8018eb4b51 --- /dev/null +++ b/mm2src/coins/eth/tron/withdraw.rs @@ -0,0 +1,319 @@ +//! TRON withdrawal pipeline. +//! +//! Standalone functions that build, estimate fees for, and prepare TRON withdrawal +//! transactions (TRX native and TRC20 token). Signing and `TransactionDetails` +//! assembly happen in the calling `EthWithdraw` trait (see `eth_withdraw.rs`). + +use crate::eth::chain_rpc::ChainRpcOps; +use crate::eth::tron::fee::{ + estimate_trc20_transfer_fee, estimate_trx_transfer_fee, tx_with_placeholder_signature, TronAccountResources, + TronChainPrices, TronTxFeeDetails, +}; +use crate::eth::tron::proto::TransactionRaw; +use crate::eth::tron::tx_builder::{build_trc20_transfer, build_trx_transfer}; +use crate::eth::tron::{TaposBlockData, TronAddress, TronApiClient, TRX_DECIMALS}; +use crate::eth::{u256_from_big_decimal, u256_to_big_decimal}; +use crate::{WithdrawError, WithdrawFee}; +use ethereum_types::U256; +use mm2_err_handle::map_mm_error::MmResultExt; +use mm2_err_handle::prelude::{MapToMmResult, MmError}; +use mm2_number::bigdecimal::BigDecimal; +use std::convert::TryInto; + +/// Shared context for TRON withdrawal operations. +/// +/// Groups the parameters common to both TRX and TRC20 withdrawals: sender/recipient +/// addresses, block data for TAPOS, account resources, chain prices, and fee coin. +pub struct TronWithdrawContext<'a> { + pub from: &'a TronAddress, + pub to: &'a TronAddress, + pub block_data: &'a TaposBlockData, + pub resources: TronAccountResources, + pub prices: TronChainPrices, + pub fee_coin: &'a str, + /// Transaction expiration window in seconds. `None` uses the protocol default (60s). + pub expiration_seconds: Option, +} + +/// Reject EVM gas fee policies for TRON. TRON always auto-estimates fees. +#[allow(clippy::result_large_err)] +pub fn validate_tron_fee_policy(fee: &Option) -> Result<(), MmError> { + match fee { + None => Ok(()), + Some(WithdrawFee::EthGas { .. }) | Some(WithdrawFee::EthGasEip1559 { .. }) => { + MmError::err(WithdrawError::InvalidFeePolicy( + "EVM gas fee options are not supported for TRON withdraw; omit the fee field".to_owned(), + )) + }, + Some(other) => MmError::err(WithdrawError::InvalidFeePolicy(format!( + "Manual fee ({:?}) is not supported for TRON withdraw; omit the fee field", + other + ))), + } +} + +/// Convert a U256 to u64, returning `WithdrawError` on overflow. +#[allow(clippy::result_large_err)] +pub fn u256_to_u64_checked(value: U256) -> Result> { + if value > U256::from(u64::MAX) { + return MmError::err(WithdrawError::InternalError(format!("value {value} exceeds u64::MAX"))); + } + Ok(value.as_u64()) +} + +/// Build TRX (native) withdraw: estimate fees, handle max-deduction, return final tx raw. +#[allow(clippy::result_large_err)] +pub fn build_tron_trx_withdraw( + ctx: &TronWithdrawContext, + amount_base_units: U256, + my_balance: U256, + my_balance_dec: &BigDecimal, + is_max: bool, +) -> Result<(TransactionRaw, TronTxFeeDetails, U256), MmError> { + let balance_sun = u256_to_u64_checked(my_balance)?; + let mut amount_sun = u256_to_u64_checked(amount_base_units)?; + let mut amount_sun_i64: i64 = amount_sun + .try_into() + .map_to_mm(|_| WithdrawError::InternalError(format!("amount {amount_sun} exceeds i64::MAX")))?; + let mut raw = build_trx_transfer(ctx.from, ctx.to, amount_sun_i64, ctx.block_data, ctx.expiration_seconds); + + // Iteratively estimate fee and adjust amount until stable. + // Non-max: runs once β€” amount is fixed, just checks balance sufficiency. + // Max: converges in 1-2 iterations β€” fee depends on tx size (varint-encoded + // amount), so changing the amount can change the fee. May leave up to 1 + // bandwidth byte of dust (~1000 SUN) at varint boundaries. + loop { + // Estimate fee for the current transaction + let tx = tx_with_placeholder_signature(&raw); + let fee_details = estimate_trx_transfer_fee(&tx, ctx.resources, ctx.prices, ctx.fee_coin); + let fee_sun = u256_to_u64_checked(u256_from_big_decimal(&fee_details.total_fee, TRX_DECIMALS).map_mm_err()?)?; + + // How much can we afford to send after paying the fee? (0 if fee >= balance) + let affordable = balance_sun.saturating_sub(fee_sun); + + // Balance covers amount + fee β€” done. + if affordable >= amount_sun { + return Ok((raw, fee_details, U256::from(amount_sun))); + } + + // Non-max: amount is user-specified and can't be reduced β€” insufficient balance. + if !is_max { + let required = + u256_to_big_decimal(U256::from(amount_sun) + U256::from(fee_sun), TRX_DECIMALS).map_mm_err()?; + return MmError::err(WithdrawError::NotSufficientBalance { + coin: ctx.fee_coin.to_owned(), + available: my_balance_dec.clone(), + required, + }); + } + + // Max: fee consumes the entire balance, nothing left to send. + if affordable == 0 { + return MmError::err(WithdrawError::AmountTooLow { + amount: BigDecimal::from(0), + threshold: fee_details.total_fee.clone(), + }); + } + + // Max: reduce amount to what's affordable after fee, rebuild tx and re-estimate. + amount_sun = affordable; + amount_sun_i64 = amount_sun + .try_into() + .map_to_mm(|_| WithdrawError::InternalError(format!("amount {amount_sun} exceeds i64::MAX")))?; + raw = build_trx_transfer(ctx.from, ctx.to, amount_sun_i64, ctx.block_data, ctx.expiration_seconds); + } +} + +/// Build TRC20 withdraw: estimate energy + bandwidth fees, return final tx raw. +pub async fn build_tron_trc20_withdraw( + ctx: &TronWithdrawContext<'_>, + tron: &TronApiClient, + contract_tron: &TronAddress, + amount_base_units: U256, +) -> Result<(TransactionRaw, TronTxFeeDetails, U256), MmError> { + // Estimate energy for TRC20 transfer + let energy_used = tron + .estimate_trc20_transfer_energy(ctx.from, contract_tron, ctx.to, amount_base_units) + .await + .map_mm_err()?; + + // Compute fee_limit as full energy cap (max TRX burn allowed for energy in SUN). + // The actual paid fee is still calculated separately via `estimate_trc20_transfer_fee`. + let fee_limit_sun = energy_used.saturating_mul(ctx.prices.energy_price_sun); + let fee_limit_i64: i64 = fee_limit_sun + .try_into() + .map_to_mm(|_| WithdrawError::InternalError(format!("fee_limit {fee_limit_sun} exceeds i64::MAX")))?; + + // Build unsigned TRC20 transfer tx + let raw = build_trc20_transfer( + ctx.from, + contract_tron, + ctx.to, + amount_base_units, + fee_limit_i64, + ctx.block_data, + ctx.expiration_seconds, + ) + .map_to_mm(|e| WithdrawError::InternalError(format!("TRC20 ABI encoding failed: {e}")))?; + + // Estimate fee details (bandwidth + energy) + let tx = tx_with_placeholder_signature(&raw); + let fee_details = estimate_trc20_transfer_fee(&tx, energy_used, ctx.resources, ctx.prices, ctx.fee_coin); + + // Verify sufficient TRX balance for fees (fees are paid in TRX, not the token) + let trx_balance = tron.balance_native(*ctx.from).await.map_mm_err()?; + let total_fee_u256 = u256_from_big_decimal(&fee_details.total_fee, TRX_DECIMALS).map_mm_err()?; + + if trx_balance < total_fee_u256 { + let trx_balance_dec = u256_to_big_decimal(trx_balance, TRX_DECIMALS).map_mm_err()?; + return MmError::err(WithdrawError::NotSufficientPlatformBalanceForFee { + coin: ctx.fee_coin.to_owned(), + available: trx_balance_dec, + required: fee_details.total_fee.clone(), + }); + } + + // TRC20 max or non-max: token amount is NOT reduced by fees + Ok((raw, fee_details, amount_base_units)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::eth::tron::fee::estimate_trx_transfer_fee; + #[cfg(not(target_arch = "wasm32"))] + use crate::eth::tron::sign::sign_tron_transaction; + use crate::eth::tron::test_fixtures::{nile_block_64687673, TEST_FROM_HEX, TEST_TO_HEX}; + use crate::eth::tron::tx_builder::build_trx_transfer; + use crate::eth::tron::TronAddress; + use common::cross_test; + use mm2_number::bigdecimal::BigDecimal; + use prost::Message; + + #[cfg(target_arch = "wasm32")] + use wasm_bindgen_test::*; + + cross_test!(validate_tron_fee_policy_rejects_evm_gas_options, { + // None (auto) is accepted + assert!(validate_tron_fee_policy(&None).is_ok()); + + // EthGas is rejected + let eth_gas = Some(WithdrawFee::EthGas { + gas_price: BigDecimal::from(20), + gas: 21_000, + }); + let err = validate_tron_fee_policy(ð_gas).unwrap_err().into_inner(); + assert!(matches!(err, WithdrawError::InvalidFeePolicy(_))); + + // EthGasEip1559 is rejected + let eip1559 = Some(WithdrawFee::EthGasEip1559 { + max_priority_fee_per_gas: BigDecimal::from(2), + max_fee_per_gas: BigDecimal::from(30), + gas_option: crate::EthGasLimitOption::Calc, + }); + let err = validate_tron_fee_policy(&eip1559).unwrap_err().into_inner(); + assert!(matches!(err, WithdrawError::InvalidFeePolicy(_))); + + // Other fee types (e.g. UtxoFixed) are also rejected for TRON + let utxo = Some(WithdrawFee::UtxoFixed { + amount: BigDecimal::from(1), + }); + let err = validate_tron_fee_policy(&utxo).unwrap_err().into_inner(); + assert!(matches!(err, WithdrawError::InvalidFeePolicy(_))); + }); + + cross_test!(tron_signed_protobuf_bytes_are_not_valid_rlp, { + // Build a deterministic TRON transaction + let block_data = TaposBlockData { + number: 54_242_114, + block_id: { + let bytes = hex::decode("00000000033bab42567444cc8af3dbaeb5cf26b514b7e90b9a23424ea8392641").unwrap(); + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + arr + }, + timestamp: 1_738_799_040_000, + }; + let from = TronAddress::from_hex(TEST_FROM_HEX).unwrap(); + let to = TronAddress::from_hex(TEST_TO_HEX).unwrap(); + let raw = build_trx_transfer(&from, &to, 1_000_000, &block_data, None); + + // Sign with a deterministic test key + let secret = ethkey::Secret::from_slice(&[1u8; 32]).expect("valid test secret"); + let (_tx_id, signed_tx) = sign_tron_transaction(&raw, &secret).unwrap(); + + // Encode to protobuf bytes (what would be passed to send_raw_tx) + let tx_bytes = signed_tx.encode_to_vec(); + + // These protobuf bytes must NOT be decodable as EVM RLP + let rlp_result = crate::eth::signed_eth_tx_from_bytes(&tx_bytes); + assert!(rlp_result.is_err(), "TRON protobuf bytes must not decode as EVM RLP"); + }); + + cross_test!(trx_max_withdraw_deducts_fee_and_returns_consistent_details, { + let from = TronAddress::from_hex(TEST_FROM_HEX).unwrap(); + let to = TronAddress::from_hex(TEST_TO_HEX).unwrap(); + let block_data = nile_block_64687673(); + + let balance = U256::from(10_000_000u64); // 10 TRX + let balance_dec = BigDecimal::from(10); + let resources = TronAccountResources::default(); // no free bandwidth + let prices = TronChainPrices { + bandwidth_price_sun: 1_000, + energy_price_sun: 420, + }; + + let ctx = TronWithdrawContext { + from: &from, + to: &to, + block_data: &block_data, + resources, + prices, + fee_coin: "TRX", + expiration_seconds: None, + }; + let (raw, fee_details, final_amount) = + build_tron_trx_withdraw(&ctx, balance, balance, &balance_dec, true).unwrap(); + + // Verify: final_amount + fee <= balance + let fee_sun = + u256_to_u64_checked(u256_from_big_decimal(&fee_details.total_fee, TRX_DECIMALS).unwrap()).unwrap(); + assert!(final_amount.as_u64() + fee_sun <= balance.as_u64()); + assert!(final_amount > U256::zero()); + + // Verify fee_details corresponds to the final raw tx + let tx = tx_with_placeholder_signature(&raw); + let recomputed = estimate_trx_transfer_fee(&tx, resources, prices, "TRX"); + assert_eq!(fee_details, recomputed, "fee_details must match the final raw tx"); + }); + + cross_test!(trx_non_max_withdraw_rejects_insufficient_balance, { + let from = TronAddress::from_hex(TEST_FROM_HEX).unwrap(); + let to = TronAddress::from_hex(TEST_TO_HEX).unwrap(); + let block_data = nile_block_64687673(); + + let balance = U256::from(1_000_000u64); // 1 TRX + let balance_dec = BigDecimal::from(1); + let amount = U256::from(999_999u64); // just under 1 TRX β€” fee will push it over + let resources = TronAccountResources::default(); + let prices = TronChainPrices { + bandwidth_price_sun: 1_000, + energy_price_sun: 420, + }; + + let ctx = TronWithdrawContext { + from: &from, + to: &to, + block_data: &block_data, + resources, + prices, + fee_coin: "TRX", + expiration_seconds: None, + }; + let result = build_tron_trx_withdraw(&ctx, amount, balance, &balance_dec, false); + + assert!(result.is_err()); + let err = result.unwrap_err().into_inner(); + assert!(matches!(err, WithdrawError::NotSufficientBalance { .. })); + }); +} diff --git a/mm2src/coins/eth/v2_activation.rs b/mm2src/coins/eth/v2_activation.rs index 702946dbbe..5d278bb2e7 100644 --- a/mm2src/coins/eth/v2_activation.rs +++ b/mm2src/coins/eth/v2_activation.rs @@ -1,5 +1,5 @@ use super::*; -use crate::eth::erc20::{get_enabled_erc20_by_platform_and_contract, get_token_decimals}; +use crate::eth::erc20::get_enabled_erc20_by_platform_and_contract; use crate::eth::eth_utils::nonce_sequencer::PerNetNonceLocks; use crate::eth::wallet_connect::eth_request_wc_personal_sign; use crate::eth::web3_transport::http_transport::HttpTransport; @@ -7,6 +7,7 @@ use crate::hd_wallet::{ load_hd_accounts_from_storage, HDAccountsMutex, HDPathAccountToAddressId, HDWalletCoinStorage, HDWalletStorageError, DEFAULT_GAP_LIMIT, }; +use crate::is_wallet_only_conf; use crate::nft::get_nfts_for_activation; use crate::nft::nft_errors::{GetNftInfoError, ParseChainTypeError}; use crate::nft::nft_structs::Chain; @@ -15,7 +16,7 @@ use crate::EthMetamaskPolicy; use common::executor::AbortedError; use compatible_time::Instant; -use crypto::{trezor::TrezorError, Bip32Error, CryptoCtxError, HwError}; +use crypto::{trezor::TrezorError, Bip32Error, CryptoCtx, CryptoCtxError, HwError}; use enum_derives::EnumFromTrait; use ethereum_types::H264; use kdf_walletconnect::error::WalletConnectError; @@ -230,7 +231,8 @@ pub struct EthActivationV2Request { pub nodes: Vec, #[serde(default)] pub rpc_mode: EthRpcMode, - pub swap_contract_address: Address, + #[serde(default)] + pub swap_contract_address: Option
, #[serde(default)] pub swap_v2_contracts: Option, pub fallback_swap_contract: Option
, @@ -332,6 +334,35 @@ impl From for EthTokenActivationError { } } +// TODO: The activation error chain is convoluted. `Web3RpcError` converts to +// `EthTokenActivationError`, which converts to `InitTokensAsMmCoinsError` / +// `InitErc20Error` / `EnableTokenError` / `EthActivationV2Error`, yet nearly every +// variant (Transport, ClientConnectionFailed, CouldNotFetchBalance) collapses to +// the same final `Transport` error. Meanwhile `ClientConnectionFailed` is never +// constructed anywhere in the codebase, and the intermediate `InitTokensAsMmCoinsError` +// maps it to `CouldNotFetchBalance` (different from `Transport`) only for +// `platform_coin_with_tokens.rs` to merge them back into `Transport` one hop later. +// This whole chain should be flattened in a future error-handling refactor. +impl From for EthTokenActivationError { + fn from(e: Web3RpcError) -> Self { + match e { + Web3RpcError::Transport(msg) + | Web3RpcError::Timeout(msg) + | Web3RpcError::BadResponse(msg) + | Web3RpcError::InvalidResponse(msg) + | Web3RpcError::RemoteError { message: msg, .. } => EthTokenActivationError::Transport(msg), + // Internal/configuration errors + Web3RpcError::Internal(msg) + | Web3RpcError::InvalidGasApiConfig(msg) + | Web3RpcError::ProtocolNotSupported(msg) + | Web3RpcError::NumConversError(msg) => EthTokenActivationError::InternalError(msg), + Web3RpcError::NoSuchCoin { coin } => { + EthTokenActivationError::InternalError(format!("No such coin: {coin}")) + }, + } + } +} + impl From for EthTokenActivationError { fn from(e: GenerateSignedMessageError) -> Self { match e { @@ -468,7 +499,7 @@ impl EthCoin { return MmError::err(EthTokenActivationError::CustomTokenError( CustomTokenError::TokenWithSameContractAlreadyActivated { ticker: token.ticker().to_string(), - contract_address: protocol.token_addr.display_address(), + contract_address: self.format_raw_address(protocol.token_addr), }, )); }, @@ -478,15 +509,7 @@ impl EthCoin { } let decimals = match token_conf["decimals"].as_u64() { - None | Some(0) => get_token_decimals( - &self - .web3() - .await - .map_err(|e| EthTokenActivationError::ClientConnectionFailed(e.to_string()))?, - protocol.token_addr, - ) - .await - .map_err(EthTokenActivationError::InternalError)?, + None | Some(0) => self.token_decimals(protocol.token_addr).await.map_mm_err()?, Some(d) => d as u8, }; @@ -539,6 +562,7 @@ impl EthCoin { decimals, ticker, web3_instances: AsyncMutex::new(self.web3_instances.lock().await.clone()), + rpc_client: self.rpc_client.clone(), history_sync_state: Mutex::new(self.history_sync_state.lock().unwrap().clone()), swap_gas_fee_policy: Mutex::new(swap_gas_fee_policy), max_eth_tx_type, @@ -605,7 +629,7 @@ impl EthCoin { None }; - let nft_infos = get_nfts_for_activation(&chain, &my_address, original_url, proxy_sign) + let nft_infos = get_nfts_for_activation(&chain, &my_address.inner(), original_url, proxy_sign) .await .map_mm_err()?; let coin_type = EthCoinType::Nft { @@ -635,6 +659,7 @@ impl EthCoin { fallback_swap_contract: self.fallback_swap_contract, contract_supports_watchers: self.contract_supports_watchers, web3_instances: AsyncMutex::new(self.web3_instances.lock().await.clone()), + rpc_client: self.rpc_client.clone(), decimals: self.decimals, history_sync_state: Mutex::new(self.history_sync_state.lock().unwrap().clone()), swap_gas_fee_policy: Mutex::new(swap_gas_fee_policy), @@ -656,25 +681,46 @@ impl EthCoin { } } -/// Activate eth coin from coin config and private key build policy, -/// version 2 of the activation function, with no intrinsic tokens creation. -pub async fn eth_coin_from_conf_and_request_v2( - ctx: &MmArc, - ticker: &str, - conf: &Json, - req: EthActivationV2Request, - priv_key_build_policy: EthPrivKeyBuildPolicy, - chain_spec: ChainSpec, -) -> MmResult { - if req.swap_contract_address == Address::default() { - return Err(EthActivationV2Error::InvalidSwapContractAddr( - "swap_contract_address can't be zero address".to_string(), +/// Resolved swap contracts after validation. +struct ResolvedSwapContracts { + swap_contract_address: Address, + swap_v2_contracts: Option, + fallback_swap_contract: Option
, +} + +/// Validates and resolves all swap contract configuration. +/// - Wallet-only: swap contracts are irrelevant, accepts any values +/// - Trading-capable: validates V1 and V2 contracts as required +fn resolve_swap_contracts( + wallet_only: bool, + use_trading_proto_v2: bool, + swap_contract_address: Option
, + swap_v2_contracts: Option, + fallback_swap_contract: Option
, +) -> MmResult { + if wallet_only { + return Ok(ResolvedSwapContracts { + swap_contract_address: swap_contract_address.unwrap_or_default(), + swap_v2_contracts, + fallback_swap_contract, + }); + } + + // Trading-capable: V1 swap contract is required and must be non-zero + let swap_contract_address = swap_contract_address.ok_or_else(|| { + EthActivationV2Error::InvalidSwapContractAddr( + "swap_contract_address is required for trading-capable coins".to_string(), ) - .into()); + })?; + if swap_contract_address == Address::default() { + return MmError::err(EthActivationV2Error::InvalidSwapContractAddr( + "swap_contract_address can't be zero address".to_string(), + )); } - if ctx.use_trading_proto_v2() { - let contracts = req.swap_v2_contracts.as_ref().ok_or_else(|| { + // Trading-capable with V2 protocol: V2 contracts are required + if use_trading_proto_v2 { + let contracts = swap_v2_contracts.as_ref().ok_or_else(|| { EthActivationV2Error::InvalidPayload( "swap_v2_contracts must be provided when using trading protocol v2".to_string(), ) @@ -683,22 +729,47 @@ pub async fn eth_coin_from_conf_and_request_v2( || contracts.taker_swap_v2_contract == Address::default() || contracts.nft_maker_swap_v2_contract == Address::default() { - return Err(EthActivationV2Error::InvalidSwapContractAddr( + return MmError::err(EthActivationV2Error::InvalidSwapContractAddr( "All swap_v2_contracts addresses must be non-zero".to_string(), - ) - .into()); + )); } } - if let Some(fallback) = req.fallback_swap_contract { + // Fallback contract: if provided, must be non-zero + if let Some(fallback) = fallback_swap_contract { if fallback == Address::default() { - return Err(EthActivationV2Error::InvalidFallbackSwapContract( + return MmError::err(EthActivationV2Error::InvalidFallbackSwapContract( "fallback_swap_contract can't be zero address".to_string(), - ) - .into()); + )); } } + Ok(ResolvedSwapContracts { + swap_contract_address, + swap_v2_contracts, + fallback_swap_contract, + }) +} + +/// Activate eth coin from coin config and private key build policy, +/// version 2 of the activation function, with no intrinsic tokens creation. +pub async fn eth_coin_from_conf_and_request_v2( + ctx: &MmArc, + ticker: &str, + conf: &Json, + req: EthActivationV2Request, + priv_key_build_policy: EthPrivKeyBuildPolicy, + chain_spec: ChainSpec, +) -> MmResult { + let wallet_only = is_wallet_only_conf(conf); + let swap_contracts = resolve_swap_contracts( + wallet_only, + ctx.use_trading_proto_v2(), + req.swap_contract_address, + req.swap_v2_contracts, + req.fallback_swap_contract, + )?; + let (priv_key_policy, derivation_method) = build_address_and_priv_key_policy( ctx, ticker, @@ -706,31 +777,58 @@ pub async fn eth_coin_from_conf_and_request_v2( priv_key_build_policy, &req.path_to_address, req.gap_limit, - Some(&chain_spec), + &chain_spec, ) .await?; - let web3_instances = match (req.rpc_mode, &priv_key_policy) { - (EthRpcMode::Default, EthPrivKeyPolicy::Iguana(_) | EthPrivKeyPolicy::HDWallet { .. }) - | (EthRpcMode::Default, EthPrivKeyPolicy::Trezor) - | (EthRpcMode::Default, EthPrivKeyPolicy::WalletConnect { .. }) => { - build_web3_instances(ctx, ticker.to_string(), req.nodes.clone()).await? + // Build chain-specific RPC clients + let (web3_instances, rpc_client) = match (&chain_spec, req.rpc_mode, &priv_key_policy) { + // EVM: Standard RPC with software/hardware/external wallets + ( + ChainSpec::Evm { .. }, + EthRpcMode::Default, + EthPrivKeyPolicy::Iguana(_) + | EthPrivKeyPolicy::HDWallet { .. } + | EthPrivKeyPolicy::Trezor + | EthPrivKeyPolicy::WalletConnect { .. }, + ) => { + let web3 = build_web3_instances(ctx, ticker.to_string(), req.nodes.clone()).await?; + (web3, None) }, + + // EVM + Metamask (WASM only): Both rpc_mode and policy must be Metamask #[cfg(target_arch = "wasm32")] - (EthRpcMode::Metamask, EthPrivKeyPolicy::Metamask(_)) => { - // Metamask doesn't support native Tron + (ChainSpec::Evm { .. }, EthRpcMode::Metamask, EthPrivKeyPolicy::Metamask(_)) => { let chain_id = chain_spec.chain_id().ok_or(EthActivationV2Error::UnsupportedChain { chain: chain_spec.kind().to_string(), feature: "Metamask".to_string(), })?; - build_metamask_transport(ctx, ticker.to_string(), chain_id).await? + let web3 = build_metamask_transport(ctx, ticker.to_string(), chain_id).await?; + (web3, None) }, + + // EVM + Metamask mismatch (WASM only): policy and rpc_mode must match #[cfg(target_arch = "wasm32")] - (EthRpcMode::Default, EthPrivKeyPolicy::Metamask(_)) | (EthRpcMode::Metamask, _) => { - let error = r#"priv_key_policy="Metamask" and rpc_mode="Metamask" should be used both"#.to_string(); + (ChainSpec::Evm { .. }, EthRpcMode::Default, EthPrivKeyPolicy::Metamask(_)) + | (ChainSpec::Evm { .. }, EthRpcMode::Metamask, _) => { return MmError::err(EthActivationV2Error::ActivationFailed { ticker: ticker.to_string(), - error, + error: "priv_key_policy and rpc_mode must both be Metamask, or neither".to_string(), + }); + }, + + // TRON: Uses dedicated TRON API via ChainRpcClient, no Web3 + (ChainSpec::Tron { .. }, EthRpcMode::Default, _) => { + let tron_api = build_tron_api_client(ctx, req.nodes.clone()).await?; + (Vec::new(), Some(chain_rpc::ChainRpcClient::Tron(tron_api))) + }, + + // TRON + Metamask (WASM only): Not supported + #[cfg(target_arch = "wasm32")] + (ChainSpec::Tron { .. }, EthRpcMode::Metamask, _) => { + return MmError::err(EthActivationV2Error::UnsupportedChain { + chain: "TRON".to_string(), + feature: "Metamask".to_string(), }); }, }; @@ -766,19 +864,25 @@ pub async fn eth_coin_from_conf_and_request_v2( get_conf_param_or_from_plaform_coin(ctx, conf, &coin_type, SWAP_GAS_FEE_POLICY)?.unwrap_or_default(); let swap_gas_fee_policy: SwapGasFeePolicy = req.swap_gas_fee_policy.unwrap_or(swap_gas_fee_policy_default); + let decimals = match &chain_spec { + ChainSpec::Evm { .. } => ETH_DECIMALS, + ChainSpec::Tron { .. } => tron::TRX_DECIMALS, + }; + let coin = EthCoinImpl { priv_key_policy, derivation_method: Arc::new(derivation_method), coin_type, chain_spec, sign_message_prefix, - swap_contract_address: req.swap_contract_address, - swap_v2_contracts: req.swap_v2_contracts, - fallback_swap_contract: req.fallback_swap_contract, + swap_contract_address: swap_contracts.swap_contract_address, + swap_v2_contracts: swap_contracts.swap_v2_contracts, + fallback_swap_contract: swap_contracts.fallback_swap_contract, contract_supports_watchers: req.contract_supports_watchers, - decimals: ETH_DECIMALS, + decimals, ticker: ticker.to_string(), web3_instances: AsyncMutex::new(web3_instances), + rpc_client, history_sync_state: Mutex::new(HistorySyncState::NotEnabled), swap_gas_fee_policy: Mutex::new(swap_gas_fee_policy), max_eth_tx_type, @@ -809,13 +913,15 @@ pub(crate) async fn build_address_and_priv_key_policy( priv_key_build_policy: EthPrivKeyBuildPolicy, path_to_address: &HDPathAccountToAddressId, gap_limit: Option, - chain_spec: Option<&ChainSpec>, + chain_spec: &ChainSpec, ) -> MmResult<(EthPrivKeyPolicy, EthDerivationMethod), EthActivationV2Error> { + let family = ChainFamily::from(chain_spec); + match priv_key_build_policy { EthPrivKeyBuildPolicy::IguanaPrivKey(iguana) => { let key_pair = KeyPair::from_secret_slice(iguana.as_slice()) .map_to_mm(|e| EthActivationV2Error::InternalError(e.to_string()))?; - let address = key_pair.address(); + let address = ChainTaggedAddress::new(key_pair.address(), family); let derivation_method = DerivationMethod::SingleAddress(address); Ok((EthPrivKeyPolicy::Iguana(key_pair), derivation_method)) }, @@ -893,9 +999,10 @@ pub(crate) async fn build_address_and_priv_key_policy( }, #[cfg(target_arch = "wasm32")] EthPrivKeyBuildPolicy::Metamask(metamask_ctx) => { - let address = *metamask_ctx.check_active_eth_account().await.map_mm_err()?; + let raw_address = *metamask_ctx.check_active_eth_account().await.map_mm_err()?; let public_key_uncompressed = metamask_ctx.eth_account_pubkey_uncompressed(); let public_key = compress_public_key(public_key_uncompressed)?; + let address = ChainTaggedAddress::new(raw_address, family); Ok(( EthPrivKeyPolicy::Metamask(EthMetamaskPolicy { public_key, @@ -908,15 +1015,15 @@ pub(crate) async fn build_address_and_priv_key_policy( let wc = WalletConnectCtx::from_ctx(ctx).map_err(|e| { EthActivationV2Error::WalletConnectError(format!("Failed to get WalletConnect context: {e}")) })?; - let chain_spec = chain_spec.ok_or(EthActivationV2Error::ChainIdNotSet)?; let chain_id = chain_spec.chain_id().ok_or(EthActivationV2Error::UnsupportedChain { chain: chain_spec.kind().to_string(), feature: "WalletConnect".to_string(), })?; - let (public_key_uncompressed, address) = eth_request_wc_personal_sign(&wc, &session_topic, chain_id) + let (public_key_uncompressed, raw_address) = eth_request_wc_personal_sign(&wc, &session_topic, chain_id) .await .mm_err(|err| EthActivationV2Error::WalletConnectError(err.to_string()))?; let public_key = compress_public_key(public_key_uncompressed)?; + let address = ChainTaggedAddress::new(raw_address, family); Ok(( EthPrivKeyPolicy::WalletConnect { public_key, @@ -929,6 +1036,45 @@ pub(crate) async fn build_address_and_priv_key_policy( } } +/// Legacy EVM-only wrapper for `build_address_and_priv_key_policy`. +/// +/// This function is ONLY for legacy V1 activation paths that exclusively support EVM chains. +/// It uses `ChainFamily::Evm` for address formatting. +/// +/// # When NOT to Use +/// - V2 activation - use `build_address_and_priv_key_policy` with explicit `&ChainSpec` +/// - TRON activation - MUST use V2 activation with `ChainSpec::Tron` +/// - WalletConnect - MUST use V2 activation with explicit chain_id +pub(crate) async fn build_address_and_priv_key_policy_evm_legacy( + ctx: &MmArc, + ticker: &str, + conf: &Json, + priv_key_build_policy: EthPrivKeyBuildPolicy, + path_to_address: &HDPathAccountToAddressId, + gap_limit: Option, + chain_id: u64, +) -> MmResult<(EthPrivKeyPolicy, EthDerivationMethod), EthActivationV2Error> { + if matches!(priv_key_build_policy, EthPrivKeyBuildPolicy::WalletConnect { .. }) { + return MmError::err(EthActivationV2Error::PrivKeyPolicyNotAllowed( + PrivKeyPolicyNotAllowed::UnsupportedMethod( + "WalletConnect requires V2 activation with explicit chain_id".to_string(), + ), + )); + } + + let legacy_evm_chain_spec = ChainSpec::Evm { chain_id }; + build_address_and_priv_key_policy( + ctx, + ticker, + conf, + priv_key_build_policy, + path_to_address, + gap_limit, + &legacy_evm_chain_spec, + ) + .await +} + async fn build_web3_instances( ctx: &MmArc, coin_ticker: String, @@ -966,6 +1112,51 @@ async fn build_web3_instances( Ok(web3_instances) } +/// Build TRON RPC pool from provided nodes. +async fn build_tron_api_client( + ctx: &MmArc, + mut nodes: Vec, +) -> MmResult { + if nodes.is_empty() { + return MmError::err(EthActivationV2Error::AtLeastOneNodeRequired); + } + + let mut rng = small_rng(); + nodes.as_mut_slice().shuffle(&mut rng); + drop_mutability!(nodes); + + // Get keypair for proxy signing from P2P context + let proxy_sign_keypair = Some(Arc::new(P2PContext::fetch_from_mm_arc(ctx).keypair().clone())); + + let mut clients = Vec::with_capacity(nodes.len()); + for node in nodes { + let uri: Uri = node + .url + .parse() + .map_err(|_| EthActivationV2Error::InvalidPayload(format!("{} could not be parsed as URI", node.url)))?; + + // TRON only supports HTTP endpoints + match uri.scheme_str() { + Some("http") | Some("https") => {}, + _ => { + return MmError::err(EthActivationV2Error::InvalidPayload(format!( + "Invalid TRON node address '{}'. Only HTTP(S) is supported for TRON", + node.url + ))); + }, + } + + let tron_node = tron::TronHttpNode { + uri, + komodo_proxy: node.komodo_proxy, + }; + + clients.push(tron::TronHttpClient::new(tron_node, proxy_sign_keypair.clone())); + } + + Ok(tron::TronApiClient::new(clients)) +} + fn create_transport( ctx: &MmArc, uri: &Uri, diff --git a/mm2src/coins/eth/wallet_connect.rs b/mm2src/coins/eth/wallet_connect.rs index 467e48e7a0..44d6fa70b4 100644 --- a/mm2src/coins/eth/wallet_connect.rs +++ b/mm2src/coins/eth/wallet_connect.rs @@ -1,5 +1,5 @@ /// https://docs.reown.com/advanced/multichain/rpc-reference/ethereum-rpc -use super::{ChainSpec, EthCoin, EthPrivKeyPolicy}; +use super::{EthCoin, EthPrivKeyPolicy}; use crate::common::Future01CompatExt; use crate::hd_wallet::AddrToString; @@ -105,15 +105,12 @@ impl WalletConnectOps for EthCoin { async fn wc_chain_id(&self, wc: &WalletConnectCtx) -> Result { let session_topic = self.session_topic()?; - let chain_id = match self.chain_spec { - ChainSpec::Evm { chain_id } => chain_id, - // Todo: Add Tron signing logic - ChainSpec::Tron { .. } => { - return Err(MmError::new(EthWalletConnectError::InternalError( - "Tron is not supported for this action yet".into(), - ))) - }, - }; + // Todo: Add Tron signing logic + let chain_id = self.chain_spec.chain_id().ok_or_else(|| { + MmError::new(EthWalletConnectError::InternalError( + "Tron is not supported for this action yet".into(), + )) + })?; let chain_id = WcChainId::new_eip155(chain_id.to_string()); wc.validate_update_active_chain_id(session_topic, &chain_id) .await diff --git a/mm2src/coins/lightning.rs b/mm2src/coins/lightning.rs index 8eb3107069..c2a19ea3f0 100644 --- a/mm2src/coins/lightning.rs +++ b/mm2src/coins/lightning.rs @@ -205,6 +205,7 @@ impl LightningCoin { .find(|chan| chan.user_channel_id == uuid.as_u128()) } + #[allow(clippy::result_large_err)] // PaymentError is from external crate pub(crate) async fn pay_invoice( &self, invoice: Invoice, @@ -791,12 +792,7 @@ impl SwapOps for LightningCoin { } } - async fn extract_secret( - &self, - _secret_hash: &[u8], - spend_tx: &[u8], - _watcher_reward: bool, - ) -> Result<[u8; 32], String> { + async fn extract_secret(&self, _secret_hash: &[u8], spend_tx: &[u8]) -> Result<[u8; 32], String> { let payment_hash = payment_hash_from_slice(spend_tx).map_err(|e| e.to_string())?; let payment_hex = hex::encode(payment_hash.0); diff --git a/mm2src/coins/lp_coins.rs b/mm2src/coins/lp_coins.rs index fbdc914204..63a66a782c 100644 --- a/mm2src/coins/lp_coins.rs +++ b/mm2src/coins/lp_coins.rs @@ -26,7 +26,10 @@ clippy::swap_ptr_to_ref, clippy::forget_non_drop, clippy::doc_lazy_continuation, - clippy::needless_lifetimes // mocktopus requires explicit lifetimes + clippy::needless_lifetimes, // mocktopus requires explicit lifetimes + // TODO: Remove this allow when Rust 1.92 regression is fixed. + // See: https://github.com/rust-lang/rust/issues/147648 + unused_assignments )] #![allow(uncommon_codepoints)] @@ -81,7 +84,7 @@ use mocktopus::macros::*; use parking_lot::Mutex as PaMutex; use rpc::v1::types::{Bytes as BytesJson, H256 as H256Json, H264 as H264Json}; use rpc_command::tendermint::ibc::ChannelId; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde::{Deserialize, Serialize, Serializer}; use serde_json::{self as json, Value as Json}; use std::array::TryFromSliceError; use std::cmp::Ordering; @@ -187,7 +190,7 @@ macro_rules! try_tx_s { /// `TransactionErr:Plain` compatible `ERR` macro. macro_rules! TX_PLAIN_ERR { - ($format: expr, $($args: tt)+) => { Err(crate::TransactionErr::Plain((ERRL!($format, $($args)+)))) }; + ($format: expr, $($args: tt)+) => { Err(crate::TransactionErr::Plain(ERRL!($format, $($args)+))) }; ($format: expr) => { Err(crate::TransactionErr::Plain(ERRL!($format))) } } @@ -231,7 +234,7 @@ pub mod eth; use eth::erc20::get_erc20_ticker_by_contract_address; use eth::eth_swap_v2::{PrepareTxDataError, ValidatePaymentV2Err}; use eth::{ - eth_coin_from_conf_and_request, get_eth_address, EthCoin, EthGasDetailsErr, EthTxFeeDetails, GetEthAddressError, + eth_coin_from_conf_and_request, get_eth_address, EthCoin, EthGasDetailsErr, GetEthAddressError, GetValidEthWithdrawAddError, SignedEthTx, }; @@ -248,7 +251,7 @@ pub mod lightning; pub mod my_tx_history_v2; pub mod qrc20; -use qrc20::{qrc20_coin_with_policy, Qrc20ActivationParams, Qrc20Coin, Qrc20FeeDetails}; +use qrc20::{qrc20_coin_with_policy, Qrc20ActivationParams, Qrc20Coin}; pub mod rpc_command; use rpc_command::{ @@ -262,8 +265,7 @@ use rpc_command::{ pub mod tendermint; use tendermint::htlc::CustomTendermintMsgType; use tendermint::{ - CosmosTransaction, TendermintCoin, TendermintFeeDetails, TendermintProtocolInfo, TendermintToken, - TendermintTokenProtocolInfo, + CosmosTransaction, TendermintCoin, TendermintProtocolInfo, TendermintToken, TendermintTokenProtocolInfo, }; #[doc(hidden)] @@ -275,8 +277,11 @@ pub use test_coin::TestCoin; pub mod tx_history_storage; +pub mod tx_fee_details; +pub use tx_fee_details::TxFeeDetails; + pub mod siacoin; -use siacoin::{SiaCoin, SiaCoinActivationRequest, SiaFeeDetails, SiaTransaction, SiaTransactionTypes}; +use siacoin::{SiaCoin, SiaCoinActivationRequest, SiaTransaction, SiaTransactionTypes}; pub mod utxo; use utxo::bch::{bch_coin_with_policy, BchActivationRequest, BchCoin}; @@ -285,8 +290,8 @@ use utxo::qtum::{ QtumStakingInfosDetails, ScriptHashTypeNotSupported, }; use utxo::rpc_clients::UtxoRpcError; +use utxo::slp::slp_addr_from_pubkey_str; use utxo::slp::SlpToken; -use utxo::slp::{slp_addr_from_pubkey_str, SlpFeeDetails}; use utxo::utxo_common::{big_decimal_from_sat_unsigned, payment_script, WaitForOutputSpendErr}; use utxo::utxo_standard::{utxo_standard_coin_with_policy, UtxoStandardCoin}; use utxo::{swap_proto_v2_scripts, BlockchainNetwork, GenerateTxError, UtxoActivationParams, UtxoFeeDetails, UtxoTx}; @@ -301,7 +306,6 @@ use crate::hd_wallet::{AddrToString, DisplayAddress}; use z_coin::{ZCoin, ZcoinProtocolInfo}; pub mod solana; -use crate::solana::SolanaFeeDetails; pub type TransactionFut = Box + Send>; pub type TransactionResult = Result; @@ -852,7 +856,6 @@ pub struct SearchForSwapTxSpendInput<'a> { pub search_from_block: u64, pub swap_contract_address: &'a Option, pub swap_unique_data: &'a [u8], - pub watcher_reward: bool, } #[derive(Copy, Clone, Debug)] @@ -1177,12 +1180,7 @@ pub trait SwapOps { input: SearchForSwapTxSpendInput<'_>, ) -> Result, String>; - async fn extract_secret( - &self, - secret_hash: &[u8], - spend_tx: &[u8], - watcher_reward: bool, - ) -> Result<[u8; 32], String>; + async fn extract_secret(&self, secret_hash: &[u8], spend_tx: &[u8]) -> Result<[u8; 32], String>; /// Whether the refund transaction can be sent now /// For example: there are no additional conditions for ETH, but for some UTXO coins we should wait for @@ -2344,6 +2342,9 @@ pub struct WithdrawRequest { /// Currently, this flag is used by ETH/ERC20 coins activated with MetaMask/WalletConnect(Some wallets e.g Metamask) **only**. #[serde(default)] broadcast: bool, + /// Transaction expiration window in seconds. Currently only used by TRON (default 60s), + /// but can be expanded to any protocol that supports transaction expiry. + pub expiration_seconds: Option, } #[derive(Debug, Deserialize)] @@ -2467,80 +2468,6 @@ pub struct VerificationResponse { is_valid: bool, } -/// Please note that no type should have the same structure as another type, -/// because this enum has the `untagged` deserialization. -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(tag = "type")] -pub enum TxFeeDetails { - Utxo(UtxoFeeDetails), - Eth(EthTxFeeDetails), - Qrc20(Qrc20FeeDetails), - Slp(SlpFeeDetails), - Tendermint(TendermintFeeDetails), - Sia(SiaFeeDetails), - Solana(SolanaFeeDetails), -} - -/// Deserialize the TxFeeDetails as an untagged enum. -impl<'de> Deserialize<'de> for TxFeeDetails { - fn deserialize(deserializer: D) -> Result>::Error> - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(untagged)] - enum TxFeeDetailsUnTagged { - Utxo(UtxoFeeDetails), - Eth(EthTxFeeDetails), - Qrc20(Qrc20FeeDetails), - Slp(SlpFeeDetails), - Tendermint(TendermintFeeDetails), - Sia(SiaFeeDetails), - Solana(SolanaFeeDetails), - } - - match Deserialize::deserialize(deserializer)? { - TxFeeDetailsUnTagged::Utxo(f) => Ok(TxFeeDetails::Utxo(f)), - TxFeeDetailsUnTagged::Eth(f) => Ok(TxFeeDetails::Eth(f)), - TxFeeDetailsUnTagged::Qrc20(f) => Ok(TxFeeDetails::Qrc20(f)), - TxFeeDetailsUnTagged::Slp(f) => Ok(TxFeeDetails::Slp(f)), - TxFeeDetailsUnTagged::Tendermint(f) => Ok(TxFeeDetails::Tendermint(f)), - TxFeeDetailsUnTagged::Sia(f) => Ok(TxFeeDetails::Sia(f)), - TxFeeDetailsUnTagged::Solana(f) => Ok(TxFeeDetails::Solana(f)), - } - } -} - -impl From for TxFeeDetails { - fn from(eth_details: EthTxFeeDetails) -> Self { - TxFeeDetails::Eth(eth_details) - } -} - -impl From for TxFeeDetails { - fn from(utxo_details: UtxoFeeDetails) -> Self { - TxFeeDetails::Utxo(utxo_details) - } -} - -impl From for TxFeeDetails { - fn from(qrc20_details: Qrc20FeeDetails) -> Self { - TxFeeDetails::Qrc20(qrc20_details) - } -} - -impl From for TxFeeDetails { - fn from(sia_details: SiaFeeDetails) -> Self { - TxFeeDetails::Sia(sia_details) - } -} - -impl From for TxFeeDetails { - fn from(tendermint_details: TendermintFeeDetails) -> Self { - TxFeeDetails::Tendermint(tendermint_details) - } -} - #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct KmdRewardsDetails { amount: BigDecimal, @@ -2873,8 +2800,8 @@ pub enum TradePreimageError { #[from_stringify("NumConversError", "UnexpectedDerivationMethod")] #[display(fmt = "Internal error: {_0}")] InternalError(String), - #[display(fmt = "Nft Protocol is not supported yet!")] - NftProtocolNotSupported, + #[display(fmt = "Protocol not supported: {_0}")] + ProtocolNotSupported(String), #[display(fmt = "No such coin {}", coin)] NoSuchCoin { coin: String }, } @@ -3366,8 +3293,8 @@ pub enum WithdrawError { my_address: String, token_owner: String, }, - #[display(fmt = "Nft Protocol is not supported yet!")] - NftProtocolNotSupported, + #[display(fmt = "Protocol not supported: {_0}")] + ProtocolNotSupported(String), #[display(fmt = "Chain id must be set for typed transaction for coin {coin}")] NoChainIdSet { coin: String, @@ -3412,7 +3339,7 @@ impl HttpStatusCode for WithdrawError { WithdrawError::HwError(_) => StatusCode::GONE, #[cfg(target_arch = "wasm32")] WithdrawError::BroadcastExpected(_) => StatusCode::BAD_REQUEST, - WithdrawError::InternalError(_) | WithdrawError::DbError(_) | WithdrawError::NftProtocolNotSupported => { + WithdrawError::InternalError(_) | WithdrawError::DbError(_) | WithdrawError::ProtocolNotSupported(_) => { StatusCode::INTERNAL_SERVER_ERROR }, WithdrawError::Transport(_) => StatusCode::BAD_GATEWAY, @@ -3511,7 +3438,7 @@ impl From for WithdrawError { }, EthGasDetailsErr::Internal(e) => WithdrawError::InternalError(e), EthGasDetailsErr::Transport(e) => WithdrawError::Transport(e), - EthGasDetailsErr::NftProtocolNotSupported => WithdrawError::NftProtocolNotSupported, + EthGasDetailsErr::ProtocolNotSupported(e) => WithdrawError::ProtocolNotSupported(e), EthGasDetailsErr::NoSuchCoin { coin } => WithdrawError::NoSuchCoin { coin }, } } @@ -4181,22 +4108,22 @@ impl DexFee { DexFee::Standard(dex_fee) } - /// Returns dex fee discount if KMD is traded + /// Returns DEX fee rate. GLEEC trades get a 50% discount (1% vs 2% base rate). pub fn dex_fee_rate(base: &str, rel: &str) -> MmNumber { #[cfg(any(feature = "for-tests", test))] let fee_discount_tickers: &[&str] = match std::env::var("MYCOIN_FEE_DISCOUNT") { - Ok(_) => &["KMD", "MYCOIN"], - Err(_) => &["KMD"], + Ok(_) => &["GLEEC", "MYCOIN"], + Err(_) => &["GLEEC"], }; - #[cfg(not(any(feature = "for-tests", test)))] - let fee_discount_tickers: &[&str] = &["KMD"]; + let fee_discount_tickers: &[&str] = &["GLEEC"]; if fee_discount_tickers.contains(&base) || fee_discount_tickers.contains(&rel) { - // 1/777 - 10% - BigRational::new(9.into(), 7770.into()).into() + // 1% fee (50% discount) + BigRational::new(1.into(), 100.into()).into() } else { - BigRational::new(1.into(), 777.into()).into() + // 2% fee (standard rate) + BigRational::new(2.into(), 100.into()).into() } } @@ -4655,6 +4582,10 @@ impl PrivKeyPolicy { fn is_trezor(&self) -> bool { matches!(self, PrivKeyPolicy::Trezor) } + + fn is_internal(&self) -> bool { + matches!(self, PrivKeyPolicy::Iguana(_) | PrivKeyPolicy::HDWallet { .. }) + } } /// 'CoinWithPrivKeyPolicy' trait is used to get the private key policy of a coin. @@ -4893,7 +4824,7 @@ pub struct UtxoProtocolInfo { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "type", content = "protocol_data")] pub enum CoinProtocol { - // TODO: Nest this option deep into the innert struct fields when more fields are added to the UTXO protocol info. + // TODO: Nest this option deep into the inner struct fields when more fields are added to the UTXO protocol info. UTXO(Option), QTUM, QRC20 { @@ -4912,7 +4843,11 @@ pub enum CoinProtocol { TRX { network: eth::tron::Network, }, - // Todo: Add TRC20, Do we need to support TRC10? + TRC20 { + platform: String, + contract_address: String, + }, + // Todo: Do we need to support TRC10? SLPTOKEN { platform: String, token_id: H256Json, @@ -4967,6 +4902,7 @@ impl CoinProtocol { match self { CoinProtocol::QRC20 { platform, .. } | CoinProtocol::ERC20 { platform, .. } + | CoinProtocol::TRC20 { platform, .. } | CoinProtocol::SLPTOKEN { platform, .. } | CoinProtocol::NFT { platform, .. } => Some(platform), CoinProtocol::TENDERMINTTOKEN(info) => Some(&info.platform), @@ -4988,9 +4924,9 @@ impl CoinProtocol { /// Returns the contract address associated with the coin, if any. pub fn contract_address(&self) -> Option { match self { - CoinProtocol::QRC20 { contract_address, .. } | CoinProtocol::ERC20 { contract_address, .. } => { - Some(contract_address.clone()) - }, + CoinProtocol::QRC20 { contract_address, .. } + | CoinProtocol::ERC20 { contract_address, .. } + | CoinProtocol::TRC20 { contract_address, .. } => Some(contract_address.clone()), CoinProtocol::SLPTOKEN { .. } | CoinProtocol::UTXO { .. } | CoinProtocol::QTUM @@ -5352,6 +5288,7 @@ pub async fn lp_coininit(ctx: &MmArc, ticker: &str, req: &Json) -> Result return ERR!("ZHTLC protocol is not supported by lp_coininit"), CoinProtocol::NFT { .. } => return ERR!("NFT protocol is not supported by lp_coininit"), CoinProtocol::TRX { .. } => return ERR!("TRX protocol is not supported by lp_coininit"), + CoinProtocol::TRC20 { .. } => return ERR!("TRC20 protocol is not supported by lp_coininit"), #[cfg(not(target_arch = "wasm32"))] CoinProtocol::LIGHTNING { .. } => return ERR!("Lightning protocol is not supported by lp_coininit"), CoinProtocol::SIA => { @@ -5969,8 +5906,13 @@ pub fn address_by_coin_conf_and_pubkey_str( CoinProtocol::ERC20 { .. } | CoinProtocol::ETH { .. } | CoinProtocol::NFT { .. } => { eth::addr_from_pubkey_str(pubkey) }, - // Todo: implement TRX address generation - CoinProtocol::TRX { .. } => ERR!("TRX address generation is not implemented yet"), + CoinProtocol::TRX { .. } | CoinProtocol::TRC20 { .. } => { + let pubkey_hex = pubkey.strip_prefix("0x").unwrap_or(pubkey); + let pubkey_bytes = hex::decode(pubkey_hex).map_err(|e| ERRL!("{}", e))?; + let raw_addr = eth::addr_from_raw_pubkey(&pubkey_bytes)?; + let tron_addr = eth::tron::TronAddress::from(raw_addr); + Ok(tron_addr.to_base58()) + }, CoinProtocol::UTXO { .. } | CoinProtocol::QTUM | CoinProtocol::QRC20 { .. } | CoinProtocol::BCH { .. } => { utxo::address_by_conf_and_pubkey_str(coin, conf, pubkey, addr_format) }, @@ -6474,6 +6416,7 @@ mod tests { #[test] fn test_dex_fee_amount() { + // BTC WithBurn, burn enabled by mocking let base = "BTC"; let btc = TestCoin::new(base); TestCoin::should_burn_dex_fee.mock_safe(|_| MockResult::Return(true)); @@ -6482,22 +6425,24 @@ mod tests { let amount = 1.into(); let actual_fee = DexFee::new_from_taker_coin(&btc, rel, &amount); let expected_fee = DexFee::WithBurn { - fee_amount: amount.clone() / 777u64.into() * "0.75".into(), - burn_amount: amount / 777u64.into() * "0.25".into(), + fee_amount: amount.clone() * "0.02".into() * "0.75".into(), + burn_amount: amount * "0.02".into() * "0.25".into(), burn_destination: DexFeeBurnDestination::PreBurnAccount, }; assert_eq!(expected_fee, actual_fee); TestCoin::should_burn_dex_fee.clear_mock(); + // KMD WithBurn - same 2% rate as other coins (no KMD discount anymore) + // KMD uses should_burn_directly() -> KmdOpReturn let base = "KMD"; let kmd = TestCoin::new(base); - TestCoin::should_burn_dex_fee.mock_safe(|_| MockResult::Return(true)); + TestCoin::should_burn_directly.mock_safe(|_| MockResult::Return(true)); TestCoin::min_tx_amount.mock_safe(|_| MockResult::Return(MmNumber::from("0.0001").into())); let rel = "ETH"; let amount = 1.into(); let actual_fee = DexFee::new_from_taker_coin(&kmd, rel, &amount); - let expected_fee = amount.clone() * (9, 7770).into() * MmNumber::from("0.75"); - let expected_burn_amount = amount * (9, 7770).into() * MmNumber::from("0.25"); + let expected_fee = amount.clone() * "0.02".into() * MmNumber::from("0.75"); + let expected_burn_amount = amount * "0.02".into() * MmNumber::from("0.25"); assert_eq!( DexFee::WithBurn { fee_amount: expected_fee, @@ -6506,26 +6451,31 @@ mod tests { }, actual_fee ); - TestCoin::should_burn_dex_fee.clear_mock(); + TestCoin::should_burn_directly.clear_mock(); // check the case when KMD taker fee is close to dust (0.75 of fee < dust) let base = "KMD"; let kmd = TestCoin::new(base); - TestCoin::should_burn_dex_fee.mock_safe(|_| MockResult::Return(true)); + TestCoin::should_burn_directly.mock_safe(|_| MockResult::Return(true)); TestCoin::min_tx_amount.mock_safe(|_| MockResult::Return(MmNumber::from("0.00001").into())); + // With 2% rate: need amount where fee portion (75%) < min_tx_amount + // fee = amount * 0.02 * 0.75 < 0.00001 => amount < 0.00001 / 0.015 β‰ˆ 0.000667 + // Using amount = 0.0006: total = 0.000012, fee (75%) = 0.000009 < min, gets clamped to min let rel = "BTC"; - let amount = (1001 * 777, 90000000).into(); + let amount = "0.0006".into(); let actual_fee = DexFee::new_from_taker_coin(&kmd, rel, &amount); + // fee gets clamped to min_tx_amount, burn = total - fee = 0.000012 - 0.00001 = 0.000002 assert_eq!( DexFee::WithBurn { fee_amount: "0.00001".into(), // equals to min_tx_amount - burn_amount: "0.00000001".into(), + burn_amount: "0.000002".into(), burn_destination: DexFeeBurnDestination::KmdOpReturn, }, actual_fee ); - TestCoin::should_burn_dex_fee.clear_mock(); + TestCoin::should_burn_directly.clear_mock(); + // BTC WithBurn with smaller min_tx_amount let base = "BTC"; let btc = TestCoin::new(base); TestCoin::should_burn_dex_fee.mock_safe(|_| MockResult::Return(true)); @@ -6534,55 +6484,58 @@ mod tests { let amount = 1.into(); let actual_fee = DexFee::new_from_taker_coin(&btc, rel, &amount); let expected_fee = DexFee::WithBurn { - fee_amount: amount.clone() * (9, 7770).into() * "0.75".into(), - burn_amount: amount * (9, 7770).into() * "0.25".into(), + fee_amount: amount.clone() * "0.02".into() * "0.75".into(), + burn_amount: amount * "0.02".into() * "0.25".into(), burn_destination: DexFeeBurnDestination::PreBurnAccount, }; assert_eq!(expected_fee, actual_fee); TestCoin::should_burn_dex_fee.clear_mock(); - // whole dex fee (0.001 * 9 / 7770) less than min tx amount (0.00001) + // whole dex fee (amount * 0.02) less than min tx amount (0.00001) let base = "BTC"; let btc = TestCoin::new(base); TestCoin::should_burn_dex_fee.mock_safe(|_| MockResult::Return(true)); TestCoin::min_tx_amount.mock_safe(|_| MockResult::Return(MmNumber::from("0.00001").into())); let rel = "KMD"; - let amount: MmNumber = "0.001".parse::().unwrap().into(); + // 2% of 0.0001 = 0.000002 < min (0.00001) + let amount: MmNumber = "0.0001".parse::().unwrap().into(); let actual_fee = DexFee::new_from_taker_coin(&btc, rel, &amount); assert_eq!(DexFee::Standard("0.00001".into()), actual_fee); TestCoin::should_burn_dex_fee.clear_mock(); - // 75% of dex fee (0.03 * 9/7770 * 0.75) is over the min tx amount (0.00001) + // 75% of dex fee is over the min tx amount (0.00001) // but non-kmd burn amount is less than the min tx amount let base = "BTC"; let btc = TestCoin::new(base); TestCoin::should_burn_dex_fee.mock_safe(|_| MockResult::Return(true)); TestCoin::min_tx_amount.mock_safe(|_| MockResult::Return(MmNumber::from("0.00001").into())); let rel = "KMD"; - let amount: MmNumber = "0.03".parse::().unwrap().into(); + // 2% of 0.001 = 0.00002, fee = 0.000015 > min, burn = 0.000005 < min + let amount: MmNumber = "0.001".parse::().unwrap().into(); let actual_fee = DexFee::new_from_taker_coin(&btc, rel, &amount); - assert_eq!(DexFee::Standard(amount * (9, 7770).into()), actual_fee); + assert_eq!(DexFee::Standard(amount * "0.02".into()), actual_fee); TestCoin::should_burn_dex_fee.clear_mock(); // burning from eth currently not supported let base = "USDT-ERC20"; - let btc = TestCoin::new(base); + let erc20 = TestCoin::new(base); TestCoin::should_burn_dex_fee.mock_safe(|_| MockResult::Return(false)); TestCoin::min_tx_amount.mock_safe(|_| MockResult::Return(MmNumber::from("0.00001").into())); let rel = "BTC"; let amount: MmNumber = "1".parse::().unwrap().into(); - let actual_fee = DexFee::new_from_taker_coin(&btc, rel, &amount); - assert_eq!(DexFee::Standard(amount / "777".into()), actual_fee); + let actual_fee = DexFee::new_from_taker_coin(&erc20, rel, &amount); + assert_eq!(DexFee::Standard(amount * "0.02".into()), actual_fee); TestCoin::should_burn_dex_fee.clear_mock(); + // NUCLEUS WithBurn let base = "NUCLEUS"; - let btc = TestCoin::new(base); + let nucleus = TestCoin::new(base); TestCoin::should_burn_dex_fee.mock_safe(|_| MockResult::Return(true)); TestCoin::min_tx_amount.mock_safe(|_| MockResult::Return(MmNumber::from("0.000001").into())); let rel = "IRIS"; let amount: MmNumber = "0.008".parse::().unwrap().into(); - let actual_fee = DexFee::new_from_taker_coin(&btc, rel, &amount); - let std_fee = amount / "777".into(); + let actual_fee = DexFee::new_from_taker_coin(&nucleus, rel, &amount); + let std_fee = amount * "0.02".into(); let fee_amount = std_fee.clone() * "0.75".into(); let burn_amount = std_fee - fee_amount.clone(); assert_eq!( @@ -6601,12 +6554,61 @@ mod tests { TestCoin::should_burn_dex_fee.mock_safe(|_| MockResult::Return(true)); TestCoin::dex_pubkey.mock_safe(|_| MockResult::Return(DEX_BURN_ADDR_RAW_PUBKEY.as_slice())); TestCoin::min_tx_amount.mock_safe(|_| MockResult::Return(MmNumber::from("0.00001").into())); - let rel = "KMD"; let amount: MmNumber = "0.03".parse::().unwrap().into(); + let rel = "KMD"; let actual_fee = DexFee::new_with_taker_pubkey(&btc, rel, &amount, DEX_BURN_ADDR_RAW_PUBKEY.as_slice()); assert_eq!(DexFee::NoFee, actual_fee); TestCoin::should_burn_dex_fee.clear_mock(); TestCoin::dex_pubkey.clear_mock(); + + // ============================================================================ + // Production behavior (burn disabled) + // ============================================================================ + + // Standard 2% fee for BTC (burn disabled in production) + let base = "BTC"; + let btc = TestCoin::new(base); + TestCoin::min_tx_amount.mock_safe(|_| MockResult::Return(MmNumber::from("0.0001").into())); + let rel = "ETH"; + let amount: MmNumber = 1.into(); + let actual_fee = DexFee::new_from_taker_coin(&btc, rel, &amount); + assert_eq!(DexFee::Standard("0.02".into()), actual_fee); + + // Large trade amount + let base = "BTC"; + let btc = TestCoin::new(base); + TestCoin::min_tx_amount.mock_safe(|_| MockResult::Return(MmNumber::from("0.00001").into())); + let rel = "ETH"; + let amount: MmNumber = "1000".parse::().unwrap().into(); + let actual_fee = DexFee::new_from_taker_coin(&btc, rel, &amount); + assert_eq!(DexFee::Standard("20".into()), actual_fee); + + // Fractional amount with precise 2% calculation + let base = "BTC"; + let btc = TestCoin::new(base); + TestCoin::min_tx_amount.mock_safe(|_| MockResult::Return(MmNumber::from("0.00001").into())); + let rel = "ETH"; + let amount: MmNumber = "0.5".parse::().unwrap().into(); + let actual_fee = DexFee::new_from_taker_coin(&btc, rel, &amount); + assert_eq!(DexFee::Standard("0.01".into()), actual_fee); + + // GLEEC discount test: 1% fee instead of 2% + let gleec = TestCoin::new("GLEEC"); + TestCoin::min_tx_amount.mock_safe(|_| MockResult::Return(MmNumber::from("0.00001").into())); + let rel = "BTC"; + let amount: MmNumber = 1.into(); + let actual_fee = DexFee::new_from_taker_coin(&gleec, rel, &amount); + assert_eq!(DexFee::Standard("0.01".into()), actual_fee); + + // GLEEC as maker_ticker also gets discount + let btc = TestCoin::new("BTC"); + TestCoin::min_tx_amount.mock_safe(|_| MockResult::Return(MmNumber::from("0.00001").into())); + let rel = "GLEEC"; + let amount: MmNumber = 1.into(); + let actual_fee = DexFee::new_from_taker_coin(&btc, rel, &amount); + assert_eq!(DexFee::Standard("0.01".into()), actual_fee); + + TestCoin::min_tx_amount.clear_mock(); } } diff --git a/mm2src/coins/lp_price.rs b/mm2src/coins/lp_price.rs index 1d5d0e5c85..0ccb939e01 100644 --- a/mm2src/coins/lp_price.rs +++ b/mm2src/coins/lp_price.rs @@ -12,9 +12,9 @@ use std::str::FromStr; use std::str::Utf8Error; pub const PRICE_ENDPOINTS: [&str; 3] = [ - "https://prices.komodian.info/api/v2/tickers", + "https://prices.gleec.com/api/v2/tickers", "https://prices.cipig.net:1717/api/v2/tickers", - "https://cache.defi-stats.komodo.earth/api/v3/prices/tickers_v2.json", + "https://defistats.gleec.com/api/v3/prices/tickers_v2", ]; #[derive(Debug)] @@ -298,6 +298,7 @@ pub async fn fetch_swap_coins_price(base: Option, rel: Option) - #[cfg(not(target_arch = "wasm32"))] mod tests { #[test] + #[ignore] // Requires external API access fn test_process_price_request() { use common::block_on; @@ -308,6 +309,7 @@ mod tests { } #[test] + #[ignore] // Requires external API access fn test_fetch_swap_coins_price() { use common::block_on; diff --git a/mm2src/coins/nft/nft_tests.rs b/mm2src/coins/nft/nft_tests.rs index 8c00067bca..a432e1562a 100644 --- a/mm2src/coins/nft/nft_tests.rs +++ b/mm2src/coins/nft/nft_tests.rs @@ -1,36 +1,38 @@ use crate::hd_wallet::AddrToString; -use crate::nft::nft_structs::{ - Chain, NftFromMoralis, NftListFilters, NftTransferHistoryFilters, NftTransferHistoryFromMoralis, PhishingDomainReq, - PhishingDomainRes, SpamContractReq, SpamContractRes, TransferMeta, -}; +use crate::nft::nft_structs::{Chain, NftListFilters, NftTransferHistoryFilters, TransferMeta}; use crate::nft::storage::db_test_helpers::{get_nft_ctx, nft, nft_list, nft_transfer_history}; use crate::nft::storage::{NftListStorageOps, NftTransferHistoryStorageOps, RemoveNftResult}; use crate::nft::{ check_moralis_ipfs_bafy, get_domain_from_url, is_malicious, process_metadata_for_spam_link, process_text_for_spam_link, }; -use common::cross_test; -use ethereum_types::Address; -use mm2_net::transport::send_post_request_to_uri; +use common::{cfg_native, cfg_wasm32, cross_test}; use mm2_number::{BigDecimal, BigUint}; use std::num::NonZeroUsize; use std::str::FromStr; -const MORALIS_API_ENDPOINT_TEST: &str = "https://moralis-proxy.komodian.info/api/v2"; -const TEST_WALLET_ADDR_EVM: &str = "0x394d86994f954ed931b86791b62fe64f4c5dac37"; -const BLOCKLIST_API_ENDPOINT: &str = "https://nft.antispam.dragonhound.info"; const TOKEN_ADD: &str = "0xfd913a305d70a60aac4faac70c739563738e1f81"; const TOKEN_ID: &str = "214300044414"; const TX_HASH: &str = "0x1e9f04e9b571b283bde02c98c2a97da39b2bb665b57c1f2b0b733f9b681debbe"; const LOG_INDEX: u32 = 495; -#[cfg(not(target_arch = "wasm32"))] -use mm2_net::native_http::send_request_to_uri; +cfg_native! { + use crate::nft::nft_structs::{ + NftFromMoralis, NftTransferHistoryFromMoralis, PhishingDomainReq, PhishingDomainRes, SpamContractReq, + SpamContractRes, + }; + use ethereum_types::Address; + use mm2_net::native_http::send_request_to_uri; + use mm2_net::transport::send_post_request_to_uri; -common::cfg_wasm32! { + const MORALIS_API_ENDPOINT_TEST: &str = "https://moralis.gleec.com/api/v2"; + const TEST_WALLET_ADDR_EVM: &str = "0x394d86994f954ed931b86791b62fe64f4c5dac37"; + const BLOCKLIST_API_ENDPOINT: &str = "https://nft-antispam.gleec.com"; +} + +cfg_wasm32! { use wasm_bindgen_test::*; wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); - use mm2_net::wasm::http::send_request_to_uri; } cross_test!(test_is_malicious, { @@ -92,7 +94,12 @@ cross_test!(test_check_for_spam_links, { assert_eq!(meta_redacted, nft.common.metadata.unwrap()) }); -cross_test!(test_moralis_requests, { +// Ignored: depends on external Moralis API which may be rate-limited or unavailable. +// Run manually with: cargo test test_moralis_requests -- --ignored +#[cfg(not(target_arch = "wasm32"))] +#[tokio::test(flavor = "multi_thread")] +#[ignore] +async fn test_moralis_requests() { let uri_nft_list = format!("{MORALIS_API_ENDPOINT_TEST}/{TEST_WALLET_ADDR_EVM}/nft?chain=POLYGON&format=decimal"); let response_nft_list = send_request_to_uri(uri_nft_list.as_str(), None).await.unwrap(); let nfts_list = response_nft_list["result"].as_array().unwrap(); @@ -119,9 +126,14 @@ cross_test!(test_moralis_requests, { let response_meta = send_request_to_uri(uri_meta.as_str(), None).await.unwrap(); let nft_moralis: NftFromMoralis = serde_json::from_str(&response_meta.to_string()).unwrap(); assert_eq!(42563567, nft_moralis.block_number.0); -}); +} -cross_test!(test_antispam_scan_endpoints, { +// Ignored: depends on external antispam API which may be unavailable. +// Run manually with: cargo test test_antispam_scan_endpoints -- --ignored +#[cfg(not(target_arch = "wasm32"))] +#[tokio::test(flavor = "multi_thread")] +#[ignore] +async fn test_antispam_scan_endpoints() { let req_spam = SpamContractReq { network: Chain::Eth, addresses: "0x0ded8542fc8b2b4e781b96e99fee6406550c9b7c,0x8d1355b65da254f2cc4611453adfa8b7a13f60ee".to_string(), @@ -130,14 +142,13 @@ cross_test!(test_antispam_scan_endpoints, { let req_json = serde_json::to_string(&req_spam).unwrap(); let contract_scan_res = send_post_request_to_uri(uri_contract.as_str(), req_json).await.unwrap(); let spam_res: SpamContractRes = serde_json::from_slice(&contract_scan_res).unwrap(); + // Only verify addresses are in the response; spam status may change over time assert!(spam_res .result - .get(&Address::from_str("0x0ded8542fc8b2b4e781b96e99fee6406550c9b7c").unwrap()) - .unwrap()); + .contains_key(&Address::from_str("0x0ded8542fc8b2b4e781b96e99fee6406550c9b7c").unwrap())); assert!(spam_res .result - .get(&Address::from_str("0x8d1355b65da254f2cc4611453adfa8b7a13f60ee").unwrap()) - .unwrap()); + .contains_key(&Address::from_str("0x8d1355b65da254f2cc4611453adfa8b7a13f60ee").unwrap())); let req_phishing = PhishingDomainReq { domains: "disposal-account-case-1f677.web.app,defi8090.vip".to_string(), @@ -146,27 +157,28 @@ cross_test!(test_antispam_scan_endpoints, { let uri_domain = format!("{BLOCKLIST_API_ENDPOINT}/api/blocklist/domain/scan"); let domain_scan_res = send_post_request_to_uri(uri_domain.as_str(), req_json).await.unwrap(); let phishing_res: PhishingDomainRes = serde_json::from_slice(&domain_scan_res).unwrap(); - assert!(phishing_res.result.get("disposal-account-case-1f677.web.app").unwrap()); -}); + // Only verify domain is in the response; phishing status may change over time + assert!(phishing_res.result.contains_key("disposal-account-case-1f677.web.app")); +} // Disabled on Linux: https://github.com/KomodoPlatform/komodo-defi-framework/issues/2367 -cross_test!( - test_camo, - { - use crate::nft::nft_structs::UriMeta; - - let hex_token_uri = hex::encode("https://tikimetadata.s3.amazonaws.com/tiki_box.json"); - let uri_decode = format!("{BLOCKLIST_API_ENDPOINT}/url/decode/{hex_token_uri}"); - let decode_res = send_request_to_uri(&uri_decode, None).await.unwrap(); - let uri_meta: UriMeta = serde_json::from_value(decode_res).unwrap(); - assert_eq!( - uri_meta.raw_image_url.unwrap(), - "https://tikimetadata.s3.amazonaws.com/tiki_box.png" - ); - }, - target_os = "macos", - target_os = "windows" -); +// Ignored: depends on external antispam API which may be unavailable. +// Run manually with: cargo test test_camo -- --ignored +#[cfg(all(not(target_arch = "wasm32"), any(target_os = "macos", target_os = "windows")))] +#[tokio::test(flavor = "multi_thread")] +#[ignore] +async fn test_camo() { + use crate::nft::nft_structs::UriMeta; + + let hex_token_uri = hex::encode("https://tikimetadata.s3.amazonaws.com/tiki_box.json"); + let uri_decode = format!("{BLOCKLIST_API_ENDPOINT}/url/decode/{hex_token_uri}"); + let decode_res = send_request_to_uri(&uri_decode, None).await.unwrap(); + let uri_meta: UriMeta = serde_json::from_value(decode_res).unwrap(); + assert_eq!( + uri_meta.raw_image_url.unwrap(), + "https://tikimetadata.s3.amazonaws.com/tiki_box.png" + ); +} cross_test!(test_add_get_nfts, { let chain = Chain::Bsc; diff --git a/mm2src/coins/qrc20.rs b/mm2src/coins/qrc20.rs index 2631f3724d..a56adf42a3 100644 --- a/mm2src/coins/qrc20.rs +++ b/mm2src/coins/qrc20.rs @@ -718,7 +718,7 @@ impl UtxoCommonOps for Qrc20Coin { utxo_common::denominate_satoshis(&self.utxo, satoshi) } - fn my_public_key(&self) -> Result<&Public, MmError> { + fn my_public_key(&self) -> Result> { utxo_common::my_public_key(self.as_ref()) } @@ -756,7 +756,7 @@ impl UtxoCommonOps for Qrc20Coin { utxo_common::get_mut_verbose_transaction_from_map_or_rpc(self, tx_hash, utxo_tx_map).await } - async fn p2sh_spending_tx(&self, input: utxo_common::P2SHSpendingTxInput<'_>) -> Result { + async fn p2sh_spending_tx(&self, input: utxo_common::P2SHSpendingTxInput) -> Result { utxo_common::p2sh_spending_tx(self, input).await } @@ -1035,12 +1035,7 @@ impl SwapOps for Qrc20Coin { } #[inline] - async fn extract_secret( - &self, - secret_hash: &[u8], - spend_tx: &[u8], - _watcher_reward: bool, - ) -> Result<[u8; 32], String> { + async fn extract_secret(&self, secret_hash: &[u8], spend_tx: &[u8]) -> Result<[u8; 32], String> { self.extract_secret_impl(secret_hash, spend_tx) } @@ -1075,7 +1070,7 @@ impl SwapOps for Qrc20Coin { } fn derive_htlc_pubkey(&self, swap_unique_data: &[u8]) -> [u8; 33] { - utxo_common::derive_htlc_pubkey(self, swap_unique_data) + utxo_common::derive_htlc_pubkey(self.as_ref(), swap_unique_data) } #[inline] diff --git a/mm2src/coins/qrc20/qrc20_tests.rs b/mm2src/coins/qrc20/qrc20_tests.rs index 335ab4320e..2be9bd66a6 100644 --- a/mm2src/coins/qrc20/qrc20_tests.rs +++ b/mm2src/coins/qrc20/qrc20_tests.rs @@ -7,6 +7,7 @@ use keys::Address; use mm2_core::mm_ctx::MmCtxBuilder; use mm2_number::bigdecimal::Zero; use mm2_test_helpers::electrums::tqtum_electrums; +use mm2_test_helpers::for_tests::DEX_FEE_ADDR_RAW_PUBKEY_LEGACY; use rpc::v1::types::ToTxHash; use std::convert::TryFrom; use std::mem::discriminant; @@ -251,7 +252,9 @@ fn test_validate_maker_payment() { } } +// TODO: Re-enable once Electrum servers are dockerized: https://github.com/KomodoPlatform/komodo-defi-framework/issues/2708 #[test] +#[ignore] fn test_wait_for_confirmations_excepted() { // this priv_key corresponds to "taker_passphrase" passphrase let priv_key = [ @@ -319,6 +322,7 @@ fn test_wait_for_confirmations_excepted() { #[test] fn test_validate_fee() { // priv_key of qXxsj5RtciAby9T7m98AgAATL4zTi4UwDG + // TODO: Update test fixtures with transactions to new DEX fee address once swaps exist use common::DEX_FEE_ADDR_RAW_PUBKEY; let priv_key = [ @@ -328,11 +332,15 @@ fn test_validate_fee() { let (_ctx, coin) = qrc20_coin_for_test(priv_key, None); // QRC20 transfer tx "f97d3a43dbea0993f1b7a6a299377d4ee164c84935a1eb7d835f70c9429e6a1d" + // This tx was sent to the OLD dex fee address, so we mock dex_pubkey to return the legacy address let tx = TransactionEnum::UtxoTx("010000000160fd74b5714172f285db2b36f0b391cd6883e7291441631c8b18f165b0a4635d020000006a47304402205d409e141111adbc4f185ae856997730de935ac30a0d2b1ccb5a6c4903db8171022024fc59bbcfdbba283556d7eeee4832167301dc8e8ad9739b7865f67b9676b226012103693bff1b39e8b5a306810023c29b95397eb395530b106b1820ea235fd81d9ce9ffffffff020000000000000000625403a08601012844a9059cbb000000000000000000000000ca1e04745e8ca0c60d8c5881531d51bec470743f00000000000000000000000000000000000000000000000000000000000f424014d362e096e873eb7907e205fadc6175c6fec7bc44c200ada205000000001976a9149e032d4b0090a11dc40fe6c47601499a35d55fbb88acfe967d5f".into()); let sender_pub = hex::decode("03693bff1b39e8b5a306810023c29b95397eb395530b106b1820ea235fd81d9ce9").unwrap(); let amount = BigDecimal::from_str("0.01").unwrap(); + // Mock to use legacy fee address for this historical tx fixture + ::dex_pubkey.mock_safe(|_| MockResult::Return(DEX_FEE_ADDR_RAW_PUBKEY_LEGACY.as_slice())); + let result = block_on(coin.validate_fee(ValidateFeeArgs { fee_tx: &tx, expected_sender: &sender_pub, @@ -342,12 +350,12 @@ fn test_validate_fee() { })); assert!(result.is_ok()); - // wrong dex address - ::dex_pubkey.mock_safe(|_| { - MockResult::Return(Box::leak(Box::new( - hex::decode("03bc2c7ba671bae4a6fc835244c9762b41647b9827d4780a89a949b984a8ddcc05").unwrap(), - ))) - }); + // wrong dex address - use a completely different pubkey + let wrong_pubkey: &'static [u8] = &[ + 3, 188, 44, 123, 166, 113, 186, 228, 166, 252, 131, 82, 68, 201, 118, 43, 65, 100, 123, 152, 39, 212, 120, 10, + 137, 169, 73, 185, 132, 168, 221, 204, 5, + ]; + ::dex_pubkey.mock_safe(move |_| MockResult::Return(wrong_pubkey)); let err = block_on(coin.validate_fee(ValidateFeeArgs { fee_tx: &tx, expected_sender: &sender_pub, @@ -362,7 +370,9 @@ fn test_validate_fee() { ValidatePaymentError::WrongPaymentTx(err) => assert!(err.contains("QRC20 Fee tx was sent to wrong address")), _ => panic!("Expected `WrongPaymentTx` wrong receiver address, found {:?}", err), } - ::dex_pubkey.clear_mock(); + + // Restore legacy mock for remaining tests with the historical tx fixture + ::dex_pubkey.mock_safe(|_| MockResult::Return(DEX_FEE_ADDR_RAW_PUBKEY_LEGACY.as_slice())); let err = block_on(coin.validate_fee(ValidateFeeArgs { fee_tx: &tx, @@ -412,6 +422,8 @@ fn test_validate_fee() { _ => panic!("Expected `WrongPaymentTx` invalid fee value, found {:?}", err), } + ::dex_pubkey.clear_mock(); + // QTUM tx "8a51f0ffd45f34974de50f07c5bf2f0949da4e88433f8f75191953a442cf9310" let tx = TransactionEnum::UtxoTx("020000000113640281c9332caeddd02a8dd0d784809e1ad87bda3c972d89d5ae41f5494b85010000006a47304402207c5c904a93310b8672f4ecdbab356b65dd869a426e92f1064a567be7ccfc61ff02203e4173b9467127f7de4682513a21efb5980e66dbed4da91dff46534b8e77c7ef012102baefe72b3591de2070c0da3853226b00f082d72daa417688b61cb18c1d543d1afeffffff020001b2c4000000001976a9149e032d4b0090a11dc40fe6c47601499a35d55fbb88acbc4dd20c2f0000001976a9144208fa7be80dcf972f767194ad365950495064a488ac76e70800".into()); let sender_pub = hex::decode("02baefe72b3591de2070c0da3853226b00f082d72daa417688b61cb18c1d543d1a").unwrap(); @@ -484,7 +496,7 @@ fn test_extract_secret() { // taker spent maker payment - d3f5dab4d54c14b3d7ed8c7f5c8cc7f47ccf45ce589fdc7cd5140a3c1c3df6e1 let tx_hex = hex::decode("01000000033f56ecafafc8602fde083ba868d1192d6649b8433e42e1a2d79ba007ea4f7abb010000006b48304502210093404e90e40d22730013035d31c404c875646dcf2fad9aa298348558b6d65ba60220297d045eac5617c1a3eddb71d4bca9772841afa3c4c9d6c68d8d2d42ee6de3950121022b00078841f37b5d30a6a1defb82b3af4d4e2d24dd4204d41f0c9ce1e875de1affffffff9cac7fe90d597922a1d92e05306c2215628e7ea6d5b855bfb4289c2944f4c73a030000006b483045022100b987da58c2c0c40ce5b6ef2a59e8124ed4ef7a8b3e60c7fb631139280019bc93022069649bcde6fe4dd5df9462a1fcae40598488d6af8c324cd083f5c08afd9568be0121022b00078841f37b5d30a6a1defb82b3af4d4e2d24dd4204d41f0c9ce1e875de1affffffff70b9870f2b0c65d220a839acecebf80f5b44c3ca4c982fa2fdc5552c037f5610010000006a473044022071b34dd3ebb72d29ca24f3fa0fc96571c815668d3b185dd45cc46a7222b6843f02206c39c030e618d411d4124f7b3e7ca1dd5436775bd8083a85712d123d933a51300121022b00078841f37b5d30a6a1defb82b3af4d4e2d24dd4204d41f0c9ce1e875de1affffffff020000000000000000c35403a0860101284ca402ed292b806a1835a1b514ad643f2acdb5c8db6b6a9714accff3275ea0d79a3f23be8fd00000000000000000000000000000000000000000000000000000000001312d000101010101010101010101010101010101010101010101010101010101010101000000000000000000000000d362e096e873eb7907e205fadc6175c6fec7bc440000000000000000000000009e032d4b0090a11dc40fe6c47601499a35d55fbb14ba8b71f3544b93e2f681f996da519a98ace0107ac2c02288d4010000001976a914783cf0be521101942da509846ea476e683aad83288ac0f047f5f").unwrap(); - let secret = block_on(coin.extract_secret(secret_hash, &tx_hex, false)).unwrap(); + let secret = block_on(coin.extract_secret(secret_hash, &tx_hex)).unwrap(); assert_eq!(secret, expected_secret); } @@ -505,7 +517,7 @@ fn test_extract_secret_malicious() { let spend_tx = hex::decode("01000000022bc8299981ec0cea664cdf9df4f8306396a02e2067d6ac2d3770b34646d2bc2a010000006b483045022100eb13ef2d99ac1cd9984045c2365654b115dd8a7815b7fbf8e2a257f0b93d1592022060d648e73118c843e97f75fafc94e5ff6da70ec8ba36ae255f8c96e2626af6260121022b00078841f37b5d30a6a1defb82b3af4d4e2d24dd4204d41f0c9ce1e875de1affffffffd92a0a10ac6d144b36033916f67ae79889f40f35096629a5cd87be1a08f40ee7010000006b48304502210080cdad5c4770dfbeb760e215494c63cc30da843b8505e75e7bf9e8dad18568000220234c0b11c41bfbcdd50046c69059976aedabe17657fe43d809af71e9635678e20121022b00078841f37b5d30a6a1defb82b3af4d4e2d24dd4204d41f0c9ce1e875de1affffffff030000000000000000c35403a0860101284ca402ed292b8620ad3b72361a5aeba5dffd333fb64750089d935a1ec974d6a91ef4f24ff6ba0000000000000000000000000000000000000000000000000000000001312d000202020202020202020202020202020202020202020202020202020202020202000000000000000000000000d362e096e873eb7907e205fadc6175c6fec7bc440000000000000000000000009e032d4b0090a11dc40fe6c47601499a35d55fbb14ba8b71f3544b93e2f681f996da519a98ace0107ac20000000000000000c35403a0860101284ca402ed292b8620ad3b72361a5aeba5dffd333fb64750089d935a1ec974d6a91ef4f24ff6ba0000000000000000000000000000000000000000000000000000000001312d000101010101010101010101010101010101010101010101010101010101010101000000000000000000000000d362e096e873eb7907e205fadc6175c6fec7bc440000000000000000000000009e032d4b0090a11dc40fe6c47601499a35d55fbb14ba8b71f3544b93e2f681f996da519a98ace0107ac2b8ea82d3010000001976a914783cf0be521101942da509846ea476e683aad83288ac735d855f").unwrap(); let expected_secret = [1; 32]; let secret_hash = &*dhash160(&expected_secret); - let actual = block_on(coin.extract_secret(secret_hash, &spend_tx, false)); + let actual = block_on(coin.extract_secret(secret_hash, &spend_tx)); assert_eq!(actual, Ok(expected_secret)); } diff --git a/mm2src/coins/rpc_command/offline_keys.rs b/mm2src/coins/rpc_command/offline_keys.rs index 572faa9f7f..22ffa83de7 100644 --- a/mm2src/coins/rpc_command/offline_keys.rs +++ b/mm2src/coins/rpc_command/offline_keys.rs @@ -676,7 +676,7 @@ pub async fn get_private_keys( } } -#[cfg(test)] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; use bitcrypto::ChecksumType; diff --git a/mm2src/coins/siacoin.rs b/mm2src/coins/siacoin.rs index cce1f0e02b..2d587af5a9 100644 --- a/mm2src/coins/siacoin.rs +++ b/mm2src/coins/siacoin.rs @@ -1385,19 +1385,11 @@ impl SiaCoin { &self, expected_hash_slice: &[u8], spend_tx: &[u8], - watcher_reward: bool, ) -> Result<[u8; 32], SiaCoinSiaExtractSecretError> { // Parse arguments to Sia specific types let tx = SiaTransaction::try_from(spend_tx)?; let expected_hash = Hash256::try_from(expected_hash_slice)?; - // watcher_reward is irrelevant, but a true value indicates a bug within the swap protocol - // An error is not thrown as it would not be in the best interest of the swap participant - // if they are still able to extract the secret and spend the HTLC output - if watcher_reward { - debug!("SiaCoin::sia_extract_secret: expected watcher_reward false, found true"); - } - // iterate over all inputs and search for preimage that hashes to expected_hash let found_secret = tx.0.siacoin_inputs @@ -1848,13 +1840,8 @@ impl SwapOps for SiaCoin { unimplemented!() } - async fn extract_secret( - &self, - secret_hash: &[u8], - spend_tx: &[u8], - watcher_reward: bool, - ) -> Result<[u8; 32], String> { - self.sia_extract_secret(secret_hash, spend_tx, watcher_reward) + async fn extract_secret(&self, secret_hash: &[u8], spend_tx: &[u8]) -> Result<[u8; 32], String> { + self.sia_extract_secret(secret_hash, spend_tx) .map_err(|e| e.to_string()) } @@ -2304,29 +2291,26 @@ mod tests { #[cfg(all(test, target_arch = "wasm32"))] mod wasm_tests { use super::*; - use common::log::info; use common::log::wasm_log::register_wasm_log; - use wasm_bindgen::prelude::*; + use sia_rust::transport::client::{ApiClient, ApiClientHelpers}; use wasm_bindgen_test::*; use url::Url; wasm_bindgen_test_configure!(run_in_browser); - async fn init_client() -> SiaClientType { + async fn init_client() -> SiaClient { let conf = SiaClientConf { server_url: Url::parse("https://api.siascan.com/wallet/api").unwrap(), headers: HashMap::new(), }; - SiaClientType::new(conf).await.unwrap() + SiaClient::new(conf).await.unwrap() } #[wasm_bindgen_test] async fn test_endpoint_txpool_broadcast() { register_wasm_log(); - use sia_rust::transaction::V2Transaction; - let client = init_client().await; let tx = serde_json::from_str::( @@ -2377,18 +2361,13 @@ mod wasm_tests { } "#).unwrap(); - let request = TxpoolBroadcastRequest { - transactions: vec![], - v2transactions: vec![tx], - }; - let resp = client.dispatcher(request).await.unwrap(); + // Use the helper which handles getting the basis (chain tip) automatically + client.broadcast_transaction(&tx).await.unwrap(); } #[wasm_bindgen_test] async fn test_helper_address_balance() { register_wasm_log(); - use sia_rust::http::endpoints::AddressBalanceRequest; - use sia_rust::types::Address; let client = init_client().await; diff --git a/mm2src/coins/solana/solana_coin.rs b/mm2src/coins/solana/solana_coin.rs index c6e29883e4..14b21566ec 100644 --- a/mm2src/coins/solana/solana_coin.rs +++ b/mm2src/coins/solana/solana_coin.rs @@ -725,12 +725,7 @@ impl SwapOps for SolanaCoin { todo!() } - async fn extract_secret( - &self, - secret_hash: &[u8], - spend_tx: &[u8], - watcher_reward: bool, - ) -> Result<[u8; 32], String> { + async fn extract_secret(&self, _secret_hash: &[u8], _spend_tx: &[u8]) -> Result<[u8; 32], String> { todo!() } diff --git a/mm2src/coins/solana/solana_token.rs b/mm2src/coins/solana/solana_token.rs index aa2d55182c..18bab84e3f 100644 --- a/mm2src/coins/solana/solana_token.rs +++ b/mm2src/coins/solana/solana_token.rs @@ -642,12 +642,7 @@ impl SwapOps for SolanaToken { todo!() } - async fn extract_secret( - &self, - secret_hash: &[u8], - spend_tx: &[u8], - watcher_reward: bool, - ) -> Result<[u8; 32], String> { + async fn extract_secret(&self, _secret_hash: &[u8], _spend_tx: &[u8]) -> Result<[u8; 32], String> { todo!() } diff --git a/mm2src/coins/tendermint/rpc/tendermint_wasm_rpc.rs b/mm2src/coins/tendermint/rpc/tendermint_wasm_rpc.rs index feb9672842..158b55fd1f 100644 --- a/mm2src/coins/tendermint/rpc/tendermint_wasm_rpc.rs +++ b/mm2src/coins/tendermint/rpc/tendermint_wasm_rpc.rs @@ -149,7 +149,7 @@ mod tests { #[wasm_bindgen_test] async fn test_get_abci_info() { - let client = HttpClient::new("http://34.80.202.172:26657", None).unwrap(); + let client = HttpClient::new("https://rpc.nyancat.irisnet.org", None).unwrap(); client.abci_info().await.unwrap(); } } diff --git a/mm2src/coins/tendermint/tendermint_coin.rs b/mm2src/coins/tendermint/tendermint_coin.rs index 1bbc1489d0..fb38972673 100644 --- a/mm2src/coins/tendermint/tendermint_coin.rs +++ b/mm2src/coins/tendermint/tendermint_coin.rs @@ -1899,7 +1899,7 @@ impl TendermintCoin { .await ); - let timeout = expires_at.checked_sub(now_sec()).unwrap_or_default(); + let timeout = expires_at.saturating_sub(now_sec()); let (_tx_id, tx_raw) = try_tx_s!( coin.common_send_raw_tx_bytes( tx_payload.clone(), @@ -4053,10 +4053,7 @@ impl SwapOps for TendermintCoin { let htlc_id = self.calculate_htlc_id(htlc.sender(), htlc.to(), &amount, maker_spends_payment_args.secret_hash); let claim_htlc_tx = try_tx_s!(self.gen_claim_htlc_tx(htlc_id, maker_spends_payment_args.secret)); - let timeout = maker_spends_payment_args - .time_lock - .checked_sub(now_sec()) - .unwrap_or_default(); + let timeout = maker_spends_payment_args.time_lock.saturating_sub(now_sec()); let coin = self.clone(); let current_block = try_tx_s!(self.current_block().compat().await); @@ -4108,10 +4105,7 @@ impl SwapOps for TendermintCoin { let htlc_id = self.calculate_htlc_id(htlc.sender(), htlc.to(), &amount, taker_spends_payment_args.secret_hash); - let timeout = taker_spends_payment_args - .time_lock - .checked_sub(now_sec()) - .unwrap_or_default(); + let timeout = taker_spends_payment_args.time_lock.saturating_sub(now_sec()); let claim_htlc_tx = try_tx_s!(self.gen_claim_htlc_tx(htlc_id, taker_spends_payment_args.secret)); let coin = self.clone(); @@ -4203,12 +4197,7 @@ impl SwapOps for TendermintCoin { self.search_for_swap_tx_spend(input).await.map_err(|e| e.to_string()) } - async fn extract_secret( - &self, - secret_hash: &[u8], - spend_tx: &[u8], - watcher_reward: bool, - ) -> Result<[u8; 32], String> { + async fn extract_secret(&self, _secret_hash: &[u8], spend_tx: &[u8]) -> Result<[u8; 32], String> { let tx = try_s!(cosmrs::Tx::from_bytes(spend_tx)); let msg = try_s!(tx.body.messages.first().ok_or("Tx body couldn't be read.")); @@ -4399,6 +4388,7 @@ pub mod tests { use common::{block_on, wait_until_ms, DEX_FEE_ADDR_RAW_PUBKEY}; use cosmrs::proto::cosmos::tx::v1beta1::{GetTxRequest, GetTxResponse}; use crypto::privkey::key_pair_from_seed; + use mm2_test_helpers::for_tests::{DEX_BURN_ADDR_RAW_PUBKEY_LEGACY, DEX_FEE_ADDR_RAW_PUBKEY_LEGACY}; use mocktopus::mocking::{MockResult, Mockable}; use std::{mem::discriminant, num::NonZeroUsize}; @@ -4416,27 +4406,27 @@ pub mod tests { ]; const IRIS_TESTNET_HTLC_PAIR2_ADDRESS: &str = "iaa1erfnkjsmalkwtvj44qnfr2drfzdt4n9ldh0kjv"; - pub const IRIS_TESTNET_RPC_URL: &str = "http://34.80.202.172:26657"; + pub const IRIS_TESTNET_RPC_URL: &str = "https://rpc.nyancat.irisnet.org"; const TAKER_PAYMENT_SPEND_SEARCH_INTERVAL: f64 = 1.; const AVG_BLOCKTIME: u8 = 5; const SUCCEED_TX_HASH_SAMPLES: &[&str] = &[ - // https://nyancat.iobscan.io/#/tx?txHash=A010FC0AA33FC6D597A8635F9D127C0A7B892FAAC72489F4DADD90048CFE9279 - "A010FC0AA33FC6D597A8635F9D127C0A7B892FAAC72489F4DADD90048CFE9279", - // https://nyancat.iobscan.io/#/tx?txHash=54FD77054AE311C484CC2EADD4621428BB23D14A9BAAC128B0E7B47422F86EC8 - "54FD77054AE311C484CC2EADD4621428BB23D14A9BAAC128B0E7B47422F86EC8", - // https://nyancat.iobscan.io/#/tx?txHash=7C00FAE7F70C36A316A4736025B08A6EAA2A0CC7919A2C4FC4CD14D9FFD166F9 - "7C00FAE7F70C36A316A4736025B08A6EAA2A0CC7919A2C4FC4CD14D9FFD166F9", + // https://nyancat.iobscan.io/#/tx?txHash=F3902E728CA9DA6250443E96087CE22B584D9C4638F938FDEE785A9D3342842C + "F3902E728CA9DA6250443E96087CE22B584D9C4638F938FDEE785A9D3342842C", + // https://nyancat.iobscan.io/#/tx?txHash=40E894173FEE18BECD7A75D6350D296121F0E2B6F45B56C8E39D2E7B29444900 + "40E894173FEE18BECD7A75D6350D296121F0E2B6F45B56C8E39D2E7B29444900", + // https://nyancat.iobscan.io/#/tx?txHash=C3A42485DFE3EE98B75F736AFF7636FE7393FF43E9F7F2D47E321373326CF300 + "C3A42485DFE3EE98B75F736AFF7636FE7393FF43E9F7F2D47E321373326CF300", ]; const FAILED_TX_HASH_SAMPLES: &[&str] = &[ - // https://nyancat.iobscan.io/#/tx?txHash=57EE62B2DF7E311C98C24AE2A53EB0FF2C16D289CECE0826CA1FF1108C91B3F9 - "57EE62B2DF7E311C98C24AE2A53EB0FF2C16D289CECE0826CA1FF1108C91B3F9", - // https://nyancat.iobscan.io/#/tx?txHash=F3181D69C580318DFD54282C656AC81113BC600BCFBAAA480E6D8A6469EE8786 - "F3181D69C580318DFD54282C656AC81113BC600BCFBAAA480E6D8A6469EE8786", - // https://nyancat.iobscan.io/#/tx?txHash=FE6F9F395DA94A14FCFC04E0E8C496197077D5F4968DA5528D9064C464ADF522 - "FE6F9F395DA94A14FCFC04E0E8C496197077D5F4968DA5528D9064C464ADF522", + // https://nyancat.iobscan.io/#/tx?txHash=0BFB105AE46F02D165759BADFF2F1F492EE35B5B091C79C8DA125A2AE84EE940 + "0BFB105AE46F02D165759BADFF2F1F492EE35B5B091C79C8DA125A2AE84EE940", + // https://nyancat.iobscan.io/#/tx?txHash=7CAC1418143FFA27687DA9DEE9C2692E024A7FB1DFE50239D6ABFAF47233F7B7 + "7CAC1418143FFA27687DA9DEE9C2692E024A7FB1DFE50239D6ABFAF47233F7B7", + // https://nyancat.iobscan.io/#/tx?txHash=B24291E9BF2AA4EF22293964F29C9C661D5FCC99AF99877D55AD1E1B82015EFE + "B24291E9BF2AA4EF22293964F29C9C661D5FCC99AF99877D55AD1E1B82015EFE", ]; fn get_iris_usdc_ibc_protocol() -> TendermintProtocolInfo { @@ -4645,7 +4635,7 @@ pub mod tests { )) .unwrap(); - let query = "claim_htlc.id='2B925FC83A106CC81590B3DB108AC2AE496FFA912F368FE5E29BC1ED2B754F2C'".to_owned(); + let query = "claim_htlc.id='FAAD30DD74C5C13FB1B7ACEEE71EE6C26784C340A137120EB52024B199E50B71'".to_owned(); let request = TxSearchRequest { query, order_by: TendermintResultOrder::Ascending.into(), @@ -4663,7 +4653,7 @@ pub mod tests { println!("{first_msg:?}"); let claim_htlc = ClaimHtlcProto::decode(HtlcType::Iris, first_msg.value.as_slice()).unwrap(); - let expected_secret = [1; 32]; + let expected_secret = [4; 32]; let actual_secret = hex::decode(claim_htlc.secret()).unwrap(); assert_eq!(actual_secret, expected_secret); @@ -4699,8 +4689,8 @@ pub mod tests { )) .unwrap(); - // https://nyancat.iobscan.io/#/tx?txHash=2DB382CE3D9953E4A94957B475B0E8A98F5B6DDB32D6BF0F6A765D949CF4A727 - let create_tx_hash = "2DB382CE3D9953E4A94957B475B0E8A98F5B6DDB32D6BF0F6A765D949CF4A727"; + // https://nyancat.iobscan.io/#/tx?txHash=C3A42485DFE3EE98B75F736AFF7636FE7393FF43E9F7F2D47E321373326CF300 + let create_tx_hash = "C3A42485DFE3EE98B75F736AFF7636FE7393FF43E9F7F2D47E321373326CF300"; let request = GetTxRequest { hash: create_tx_hash.into(), @@ -4722,7 +4712,7 @@ pub mod tests { let encoded_tx = tx.encode_to_vec(); - let secret_hash = hex::decode("0C34C71EBA2A51738699F9F3D6DAFFB15BE576E8ED543203485791B5DA39D10D").unwrap(); + let secret_hash = hex::decode("9f4fb68f3e1dac82202f9aa581ce0bbf1f765df0e9ac3c8c57e20f685abab8ed").unwrap(); let spend_tx = block_on(coin.wait_for_htlc_tx_spend(WaitForHTLCTxSpendArgs { tx_bytes: &encoded_tx, secret_hash: &secret_hash, @@ -4734,14 +4724,20 @@ pub mod tests { })) .unwrap(); - // https://nyancat.iobscan.io/#/tx?txHash=565C820C1F95556ADC251F16244AAD4E4274772F41BC13F958C9C2F89A14D137 - let expected_spend_hash = "565C820C1F95556ADC251F16244AAD4E4274772F41BC13F958C9C2F89A14D137"; + // https://nyancat.iobscan.io/#/tx?txHash=BC93B027248E0DC090B754E247C3B52A480576752CC4A0CCC1631F88BC496676 + let expected_spend_hash = "BC93B027248E0DC090B754E247C3B52A480576752CC4A0CCC1631F88BC496676"; let hash = spend_tx.tx_hash_as_bytes(); assert_eq!(hex::encode_upper(hash.0), expected_spend_hash); } + // TODO: Update test fixtures with transactions to new DEX fee address once swaps exist. + // This test uses historical tx fixtures sent to the OLD dex fee address. + // We mock dex_pubkey() to return the legacy pubkey for address derivation. #[test] fn validate_taker_fee_test() { + // Mock dex_pubkey to return legacy pubkey for historical tx fixtures + ::dex_pubkey + .mock_safe(|_| MockResult::Return(DEX_FEE_ADDR_RAW_PUBKEY_LEGACY.as_slice())); let nodes = vec![RpcNode::for_test(IRIS_TESTNET_RPC_URL)]; let protocol_conf = get_iris_protocol(); @@ -4931,12 +4927,21 @@ pub mod tests { ) .unwrap(); TendermintCoin::request_tx.clear_mock(); + ::dex_pubkey.clear_mock(); } + // This test uses historical tx fixtures sent to the OLD dex fee/burn addresses. + // Mock dex_pubkey and burn_pubkey to return legacy pubkeys for historical tx fixture validation. #[test] fn validate_taker_fee_with_burn_test() { const NUCLEUS_TEST_SEED: &str = "nucleus test seed"; + // Mock dex_pubkey and burn_pubkey to return legacy pubkeys for historical tx fixtures + ::dex_pubkey + .mock_safe(|_| MockResult::Return(DEX_FEE_ADDR_RAW_PUBKEY_LEGACY.as_slice())); + ::burn_pubkey + .mock_safe(|_| MockResult::Return(DEX_BURN_ADDR_RAW_PUBKEY_LEGACY.as_slice())); + let ctx = mm2_core::mm_ctx::MmCtxBuilder::default().into_mm_arc(); let conf = TendermintConf { avg_blocktime: AVG_BLOCKTIME, @@ -4994,6 +4999,11 @@ pub mod tests { .compat(), ) .unwrap(); + + // Clean up mocks + TendermintCoin::request_tx.clear_mock(); + ::dex_pubkey.clear_mock(); + ::burn_pubkey.clear_mock(); } #[test] @@ -5027,8 +5037,8 @@ pub mod tests { .unwrap(); // just a random transfer tx not related to AtomicDEX, should fail because the message is not CreateHtlc - // https://nyancat.iobscan.io/#/tx?txHash=65815814E7D74832D87956144C1E84801DC94FE9A509D207A0ABC3F17775E5DF - let random_transfer_tx_hash = "65815814E7D74832D87956144C1E84801DC94FE9A509D207A0ABC3F17775E5DF"; + // https://nyancat.iobscan.io/#/tx?txHash=F3902E728CA9DA6250443E96087CE22B584D9C4638F938FDEE785A9D3342842C + let random_transfer_tx_hash = "F3902E728CA9DA6250443E96087CE22B584D9C4638F938FDEE785A9D3342842C"; let random_transfer_tx_bytes = block_on(coin.request_tx(random_transfer_tx_hash.into())) .unwrap() .encode_to_vec(); @@ -5053,26 +5063,27 @@ pub mod tests { }; // The HTLC that was already claimed or refunded should not pass the validation - // https://nyancat.iobscan.io/#/tx?txHash=93CF377D470EB27BD6E2C5B95BFEFE99359F95B88C70D785B34D1D2C670201B9 - let claimed_htlc_tx_hash = "93CF377D470EB27BD6E2C5B95BFEFE99359F95B88C70D785B34D1D2C670201B9"; + // https://nyancat.iobscan.io/#/tx?txHash=41778118ABEFA7E98BD31DCD053A536E51895CDE5F06B216812EB5F70BE817E7 + let claimed_htlc_tx_hash = "41778118ABEFA7E98BD31DCD053A536E51895CDE5F06B216812EB5F70BE817E7"; let claimed_htlc_tx_bytes = block_on(coin.request_tx(claimed_htlc_tx_hash.into())) .unwrap() .encode_to_vec(); let input = ValidatePaymentInput { payment_tx: claimed_htlc_tx_bytes, - time_lock_duration: 20000, - time_lock: 1664984893, - other_pub: hex::decode("025a37975c079a7543603fcab24e2565a4adee3cf9af8934690e103282fa402511").unwrap(), - secret_hash: hex::decode("441d0237e93677d3458e1e5a2e69f61e3622813521bf048dd56290306acdd134").unwrap(), - amount: "0.01".parse().unwrap(), + time_lock_duration: 5000, + time_lock: 0, + other_pub: IRIS_TESTNET_HTLC_PAIR2_PUB_KEY.to_vec(), + secret_hash: hex::decode("f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c").unwrap(), + amount: "0.000001".parse().unwrap(), swap_contract_address: None, try_spv_proof_until: 0, confirmations: 0, unique_swap_data: Vec::new(), watcher_reward: None, }; - let validate_err = block_on(coin.validate_payment_for_denom(input, "nim".parse().unwrap(), 6)).unwrap_err(); + let validate_err = + block_on(coin.validate_payment_for_denom(input, coin.protocol_info.denom.clone(), 6)).unwrap_err(); match validate_err.into_inner() { ValidatePaymentError::UnexpectedPaymentState(_) => (), unexpected => panic!("Unexpected error variant {:?}", unexpected), @@ -5109,8 +5120,8 @@ pub mod tests { )) .unwrap(); - // https://nyancat.iobscan.io/#/tx?txHash=2DB382CE3D9953E4A94957B475B0E8A98F5B6DDB32D6BF0F6A765D949CF4A727 - let create_tx_hash = "2DB382CE3D9953E4A94957B475B0E8A98F5B6DDB32D6BF0F6A765D949CF4A727"; + // https://nyancat.iobscan.io/#/tx?txHash=C3A42485DFE3EE98B75F736AFF7636FE7393FF43E9F7F2D47E321373326CF300 + let create_tx_hash = "C3A42485DFE3EE98B75F736AFF7636FE7393FF43E9F7F2D47E321373326CF300"; let request = GetTxRequest { hash: create_tx_hash.into(), @@ -5132,7 +5143,7 @@ pub mod tests { let encoded_tx = tx.encode_to_vec(); - let secret_hash = hex::decode("0C34C71EBA2A51738699F9F3D6DAFFB15BE576E8ED543203485791B5DA39D10D").unwrap(); + let secret_hash = hex::decode("9f4fb68f3e1dac82202f9aa581ce0bbf1f765df0e9ac3c8c57e20f685abab8ed").unwrap(); let input = SearchForSwapTxSpendInput { time_lock: 0, other_pub: &[], @@ -5141,7 +5152,6 @@ pub mod tests { search_from_block: 0, swap_contract_address: &None, swap_unique_data: &[], - watcher_reward: false, }; let spend_tx = match block_on(coin.search_for_swap_tx_spend_my(input)).unwrap().unwrap() { @@ -5149,8 +5159,8 @@ pub mod tests { unexpected => panic!("Unexpected search_for_swap_tx_spend_my result {:?}", unexpected), }; - // https://nyancat.iobscan.io/#/tx?txHash=565C820C1F95556ADC251F16244AAD4E4274772F41BC13F958C9C2F89A14D137 - let expected_spend_hash = "565C820C1F95556ADC251F16244AAD4E4274772F41BC13F958C9C2F89A14D137"; + // https://nyancat.iobscan.io/#/tx?txHash=BC93B027248E0DC090B754E247C3B52A480576752CC4A0CCC1631F88BC496676 + let expected_spend_hash = "BC93B027248E0DC090B754E247C3B52A480576752CC4A0CCC1631F88BC496676"; let hash = spend_tx.tx_hash_as_bytes(); assert_eq!(hex::encode_upper(hash.0), expected_spend_hash); } @@ -5185,8 +5195,8 @@ pub mod tests { )) .unwrap(); - // https://nyancat.iobscan.io/#/tx?txHash=BD1A76F43E8E2C7A1104EE363D63455CD50C76F2BFE93B703235F0A973061297 - let create_tx_hash = "BD1A76F43E8E2C7A1104EE363D63455CD50C76F2BFE93B703235F0A973061297"; + // https://nyancat.iobscan.io/#/tx?txHash=DB102708BA64ADD5DF551843D5F1E3CC574E4640A371EB265E7824B0C854757F + let create_tx_hash = "DB102708BA64ADD5DF551843D5F1E3CC574E4640A371EB265E7824B0C854757F"; let request = GetTxRequest { hash: create_tx_hash.into(), @@ -5208,7 +5218,7 @@ pub mod tests { let encoded_tx = tx.encode_to_vec(); - let secret_hash = hex::decode("cb11cacffdfc82060aa4a9a1bb9cc094c4141b170994f7642cd54d7e7af6743e").unwrap(); + let secret_hash = hex::decode("e802086ad6a1e16b78352ad7296d2aabd835b1b16dbe951e1135b97c68e29d81").unwrap(); let input = SearchForSwapTxSpendInput { time_lock: 0, other_pub: &[], @@ -5217,7 +5227,6 @@ pub mod tests { search_from_block: 0, swap_contract_address: &None, swap_unique_data: &[], - watcher_reward: false, }; match block_on(coin.search_for_swap_tx_spend_my(input)).unwrap().unwrap() { @@ -5542,4 +5551,338 @@ pub mod tests { assert_eq!(expected_channel, actual_channel); assert_eq!(expected_channel_str, actual_channel_str); } + + /// One-off fixture generator for nyancat-9 testnet. + /// Run manually to create transactions needed by other tests: + /// cargo test -p coins --lib -- test_create_nyancat_fixtures --ignored --nocapture + /// + /// After running, update the tx hash constants in the tests above. + /// The refunded HTLC needs ~50 blocks (~250 seconds) to expire after creation. + #[test] + #[ignore] + fn test_create_nyancat_fixtures() { + // ── Setup PAIR1 coin (unyan on IRIS nyancat-9) ── + let nodes = vec![RpcNode::for_test(IRIS_TESTNET_RPC_URL)]; + let protocol_conf = get_iris_protocol(); + let ctx = mm2_core::mm_ctx::MmCtxBuilder::default().into_mm_arc(); + let conf = TendermintConf { + avg_blocktime: AVG_BLOCKTIME, + derivation_path: None, + }; + + let key_pair = key_pair_from_seed(IRIS_TESTNET_HTLC_PAIR1_SEED).unwrap(); + let tendermint_pair = TendermintKeyPair::new(key_pair.private().secret, *key_pair.public()); + let activation_policy = + TendermintActivationPolicy::with_private_key_policy(TendermintPrivKeyPolicy::Iguana(tendermint_pair)); + + let coin = block_on(TendermintCoin::init( + &ctx, + "IRIS".to_string(), + conf, + protocol_conf, + nodes, + false, + activation_policy, + Default::default(), + )) + .unwrap(); + + println!("=== PAIR1 address: {} ===", coin.account_id); + + // ── 1. Create a simple MsgSend transfer (for SUCCEED_TX_HASH_SAMPLES) ── + let to: AccountId = IRIS_TESTNET_HTLC_PAIR2_ADDRESS.parse().unwrap(); + let amount = cosmrs::Coin { + denom: coin.protocol_info.denom.clone(), + amount: 1u64.into(), + }; + let msg_send = MsgSend { + from_address: coin.account_id.clone(), + to_address: to.clone(), + amount: vec![amount], + } + .to_any() + .unwrap(); + + let current_block = block_on(async { coin.current_block().compat().await.unwrap() }); + let timeout_height = current_block + TIMEOUT_HEIGHT_DELTA; + let fee = block_on(async { + coin.calculate_fee(msg_send.clone(), timeout_height, TX_DEFAULT_MEMO, None) + .await + .unwrap() + }); + let (tx_id, _) = block_on(async { + coin.common_send_raw_tx_bytes(msg_send, fee, timeout_height, TX_DEFAULT_MEMO, Duration::from_secs(20)) + .await + .unwrap() + }); + println!("SUCCEED_TX (MsgSend transfer): {tx_id}"); + + // ── 2. Create + Claim HTLC with known secret ── + // (for try_query_claim_htlc_txs_and_get_secret, wait_for_tx_spend_test, + // test_search_for_swap_tx_spend_spent, validate_payment_test) + // NOTE: Change this secret each time you run the fixture generator, + // since IRIS rejects HTLCs with duplicate IDs. + let known_secret = [4u8; 32]; + let secret_hash = sha256(&known_secret); + let time_lock = 1000; + + let create_htlc_tx = coin + .gen_create_htlc_tx( + coin.protocol_info.denom.clone(), + &to, + 1u64.into(), + secret_hash.as_slice(), + time_lock, + ) + .unwrap(); + let htlc_id = create_htlc_tx.id.clone(); + + let current_block = block_on(async { coin.current_block().compat().await.unwrap() }); + let timeout_height = current_block + TIMEOUT_HEIGHT_DELTA; + let fee = block_on(async { + coin.calculate_fee( + create_htlc_tx.msg_payload.clone(), + timeout_height, + TX_DEFAULT_MEMO, + None, + ) + .await + .unwrap() + }); + let (create_tx_id, _) = block_on(async { + coin.common_send_raw_tx_bytes( + create_htlc_tx.msg_payload, + fee, + timeout_height, + TX_DEFAULT_MEMO, + Duration::from_secs(20), + ) + .await + .unwrap() + }); + println!("CLAIMED_HTLC CreateHTLC tx: {create_tx_id}"); + println!("CLAIMED_HTLC ID: {htlc_id}"); + println!("CLAIMED_HTLC secret_hash: {}", hex::encode(secret_hash.as_slice())); + + // Claim it + let claim_htlc_tx = coin.gen_claim_htlc_tx(htlc_id.clone(), &known_secret).unwrap(); + let current_block = block_on(async { coin.current_block().compat().await.unwrap() }); + let timeout_height = current_block + TIMEOUT_HEIGHT_DELTA; + let fee = block_on(async { + coin.calculate_fee(claim_htlc_tx.msg_payload.clone(), timeout_height, TX_DEFAULT_MEMO, None) + .await + .unwrap() + }); + let (claim_tx_id, _) = block_on(async { + coin.common_send_raw_tx_bytes( + claim_htlc_tx.msg_payload, + fee, + timeout_height, + TX_DEFAULT_MEMO, + Duration::from_secs(30), + ) + .await + .unwrap() + }); + println!("CLAIMED_HTLC ClaimHTLC tx: {claim_tx_id}"); + + // ── 2b. Create HTLC from PAIR2 β†’ PAIR1 (reverse direction) ── + // (for validate_payment_test: coin is PAIR1, validates HTLC sent TO it by PAIR2) + let key_pair2 = key_pair_from_seed("iris test2 seed").unwrap(); + let tendermint_pair2 = TendermintKeyPair::new(key_pair2.private().secret, *key_pair2.public()); + let activation_policy2 = + TendermintActivationPolicy::with_private_key_policy(TendermintPrivKeyPolicy::Iguana(tendermint_pair2)); + + let conf2 = TendermintConf { + avg_blocktime: AVG_BLOCKTIME, + derivation_path: None, + }; + let coin2 = block_on(TendermintCoin::init( + &ctx, + "IRIS".to_string(), + conf2, + get_iris_protocol(), + vec![RpcNode::for_test(IRIS_TESTNET_RPC_URL)], + false, + activation_policy2, + Default::default(), + )) + .unwrap(); + + let pair1_address: AccountId = coin.account_id.clone(); + let reverse_secret = [5u8; 32]; + let reverse_secret_hash = sha256(&reverse_secret); + let reverse_htlc_tx = coin2 + .gen_create_htlc_tx( + coin2.protocol_info.denom.clone(), + &pair1_address, + 1u64.into(), + reverse_secret_hash.as_slice(), + 1000, + ) + .unwrap(); + + let current_block = block_on(async { coin2.current_block().compat().await.unwrap() }); + let timeout_height = current_block + TIMEOUT_HEIGHT_DELTA; + let fee = block_on(async { + coin2 + .calculate_fee( + reverse_htlc_tx.msg_payload.clone(), + timeout_height, + TX_DEFAULT_MEMO, + None, + ) + .await + .unwrap() + }); + let reverse_htlc_id = reverse_htlc_tx.id.clone(); + let (reverse_create_tx_id, _) = block_on(async { + coin2 + .common_send_raw_tx_bytes( + reverse_htlc_tx.msg_payload, + fee, + timeout_height, + TX_DEFAULT_MEMO, + Duration::from_secs(20), + ) + .await + .unwrap() + }); + println!("REVERSE_HTLC (PAIR2β†’PAIR1) CreateHTLC tx: {reverse_create_tx_id}"); + println!( + "REVERSE_HTLC secret_hash: {}", + hex::encode(reverse_secret_hash.as_slice()) + ); + + // Claim the reverse HTLC (so it enters COMPLETED state for validate_payment_test) + let reverse_claim_tx = coin2.gen_claim_htlc_tx(reverse_htlc_id, &reverse_secret).unwrap(); + let current_block = block_on(async { coin2.current_block().compat().await.unwrap() }); + let timeout_height = current_block + TIMEOUT_HEIGHT_DELTA; + let fee = block_on(async { + coin2 + .calculate_fee( + reverse_claim_tx.msg_payload.clone(), + timeout_height, + TX_DEFAULT_MEMO, + None, + ) + .await + .unwrap() + }); + let (reverse_claim_tx_id, _) = block_on(async { + coin2 + .common_send_raw_tx_bytes( + reverse_claim_tx.msg_payload, + fee, + timeout_height, + TX_DEFAULT_MEMO, + Duration::from_secs(30), + ) + .await + .unwrap() + }); + println!("REVERSE_HTLC ClaimHTLC tx: {reverse_claim_tx_id}"); + + // ── 3. Create HTLC with minimum timelock for refund ── + // (for test_search_for_swap_tx_spend_refunded) + let refund_secret = [6u8; 32]; + let refund_secret_hash = sha256(&refund_secret); + let min_time_lock = 50; // minimum allowed by IRIS module + + let refund_htlc_tx = coin + .gen_create_htlc_tx( + coin.protocol_info.denom.clone(), + &to, + 1u64.into(), + refund_secret_hash.as_slice(), + min_time_lock, + ) + .unwrap(); + let refund_htlc_id = refund_htlc_tx.id.clone(); + + let current_block = block_on(async { coin.current_block().compat().await.unwrap() }); + let timeout_height = current_block + TIMEOUT_HEIGHT_DELTA; + let fee = block_on(async { + coin.calculate_fee( + refund_htlc_tx.msg_payload.clone(), + timeout_height, + TX_DEFAULT_MEMO, + None, + ) + .await + .unwrap() + }); + let (refund_create_tx_id, _) = block_on(async { + coin.common_send_raw_tx_bytes( + refund_htlc_tx.msg_payload, + fee, + timeout_height, + TX_DEFAULT_MEMO, + Duration::from_secs(20), + ) + .await + .unwrap() + }); + println!("REFUND_HTLC CreateHTLC tx: {refund_create_tx_id}"); + println!("REFUND_HTLC ID: {refund_htlc_id}"); + println!( + "REFUND_HTLC secret_hash: {}", + hex::encode(refund_secret_hash.as_slice()) + ); + println!( + "REFUND_HTLC time_lock: {min_time_lock} blocks (~{} seconds)", + min_time_lock * AVG_BLOCKTIME as u64 + ); + + // ── 4. Create failed transactions (for FAILED_TX_HASH_SAMPLES) ── + // Claim an HTLC with wrong secret β€” passes CheckTx but fails DeliverTx. + // The tx is included in the block with a non-zero error code. + // Use hardcoded fee since simulate rejects intentionally invalid txs. + for i in 0..3u8 { + let wrong_secret = [50 + i; 32]; + let wrong_claim_tx = coin.gen_claim_htlc_tx(refund_htlc_id.clone(), &wrong_secret).unwrap(); + + let account_info = block_on(async { coin.account_info(&coin.account_id).await.unwrap() }); + let current_block = block_on(async { coin.current_block().compat().await.unwrap() }); + let timeout_height = current_block + TIMEOUT_HEIGHT_DELTA; + let fee = Fee::from_amount_and_gas( + cosmrs::Coin { + denom: coin.protocol_info.denom.clone(), + amount: 25000u64.into(), + }, + GAS_LIMIT_DEFAULT, + ); + let signed_tx = coin + .any_to_signed_raw_tx( + coin.activation_policy.activated_key_or_err().unwrap(), + &account_info, + wrong_claim_tx.msg_payload, + fee, + timeout_height, + TX_DEFAULT_MEMO, + ) + .unwrap(); + + let tx_bytes = signed_tx.to_bytes().unwrap(); + let broadcast_res = block_on(async { + coin.rpc_client() + .await + .unwrap() + .broadcast_tx_commit(tx_bytes) + .await + .unwrap() + }); + assert!( + !broadcast_res.tx_result.code.is_ok(), + "Expected failed tx but got success" + ); + println!("FAILED_TX {}: {}", i + 1, broadcast_res.hash); + } + + println!(); + println!( + "=== Wait ~{} seconds for the refund HTLC to expire, then update test constants ===", + min_time_lock * AVG_BLOCKTIME as u64 + ); + } } diff --git a/mm2src/coins/tendermint/tendermint_token.rs b/mm2src/coins/tendermint/tendermint_token.rs index 8863005576..81d5f9ad29 100644 --- a/mm2src/coins/tendermint/tendermint_token.rs +++ b/mm2src/coins/tendermint/tendermint_token.rs @@ -236,15 +236,8 @@ impl SwapOps for TendermintToken { self.platform_coin.search_for_swap_tx_spend_other(input).await } - async fn extract_secret( - &self, - secret_hash: &[u8], - spend_tx: &[u8], - watcher_reward: bool, - ) -> Result<[u8; 32], String> { - self.platform_coin - .extract_secret(secret_hash, spend_tx, watcher_reward) - .await + async fn extract_secret(&self, secret_hash: &[u8], spend_tx: &[u8]) -> Result<[u8; 32], String> { + self.platform_coin.extract_secret(secret_hash, spend_tx).await } fn negotiate_swap_contract_addr( diff --git a/mm2src/coins/test_coin.rs b/mm2src/coins/test_coin.rs index ad24b86c7c..383e94fbe9 100644 --- a/mm2src/coins/test_coin.rs +++ b/mm2src/coins/test_coin.rs @@ -155,7 +155,9 @@ impl MarketCoinOps for TestCoin { } fn should_burn_directly(&self) -> bool { - &self.ticker == "KMD" + // &self.ticker == "KMD" + // Burn disabled - all fees go to DEX fee address + false } fn should_burn_dex_fee(&self) -> bool { @@ -243,12 +245,7 @@ impl SwapOps for TestCoin { unimplemented!() } - async fn extract_secret( - &self, - secret_hash: &[u8], - spend_tx: &[u8], - watcher_reward: bool, - ) -> Result<[u8; 32], String> { + async fn extract_secret(&self, _secret_hash: &[u8], _spend_tx: &[u8]) -> Result<[u8; 32], String> { unimplemented!() } diff --git a/mm2src/coins/tx_fee_details.rs b/mm2src/coins/tx_fee_details.rs new file mode 100644 index 0000000000..748e20818f --- /dev/null +++ b/mm2src/coins/tx_fee_details.rs @@ -0,0 +1,94 @@ +//! Unified fee details enum across all supported blockchain protocols. +//! +//! `TxFeeDetails` serializes with a `"type"` tag for outbound JSON, but deserializes +//! as untagged to accept responses without the discriminator field. + +use crate::eth::tron::fee::TronTxFeeDetails; +use crate::eth::EthTxFeeDetails; +use crate::qrc20::Qrc20FeeDetails; +use crate::siacoin::SiaFeeDetails; +use crate::solana::SolanaFeeDetails; +use crate::tendermint::TendermintFeeDetails; +use crate::utxo::UtxoFeeDetails; +use serde::{Deserialize, Deserializer, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "type")] +pub enum TxFeeDetails { + Utxo(UtxoFeeDetails), + Eth(EthTxFeeDetails), + Tron(TronTxFeeDetails), + Qrc20(Qrc20FeeDetails), + Slp(crate::utxo::slp::SlpFeeDetails), + Tendermint(TendermintFeeDetails), + Sia(SiaFeeDetails), + Solana(SolanaFeeDetails), +} + +/// Deserialize the TxFeeDetails as an untagged enum. +impl<'de> Deserialize<'de> for TxFeeDetails { + fn deserialize(deserializer: D) -> Result>::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum TxFeeDetailsUnTagged { + Utxo(UtxoFeeDetails), + Eth(EthTxFeeDetails), + Tron(TronTxFeeDetails), + Qrc20(Qrc20FeeDetails), + Slp(crate::utxo::slp::SlpFeeDetails), + Tendermint(TendermintFeeDetails), + Sia(SiaFeeDetails), + Solana(SolanaFeeDetails), + } + + match Deserialize::deserialize(deserializer)? { + TxFeeDetailsUnTagged::Utxo(f) => Ok(TxFeeDetails::Utxo(f)), + TxFeeDetailsUnTagged::Eth(f) => Ok(TxFeeDetails::Eth(f)), + TxFeeDetailsUnTagged::Tron(f) => Ok(TxFeeDetails::Tron(f)), + TxFeeDetailsUnTagged::Qrc20(f) => Ok(TxFeeDetails::Qrc20(f)), + TxFeeDetailsUnTagged::Slp(f) => Ok(TxFeeDetails::Slp(f)), + TxFeeDetailsUnTagged::Tendermint(f) => Ok(TxFeeDetails::Tendermint(f)), + TxFeeDetailsUnTagged::Sia(f) => Ok(TxFeeDetails::Sia(f)), + TxFeeDetailsUnTagged::Solana(f) => Ok(TxFeeDetails::Solana(f)), + } + } +} + +impl From for TxFeeDetails { + fn from(eth_details: EthTxFeeDetails) -> Self { + TxFeeDetails::Eth(eth_details) + } +} + +impl From for TxFeeDetails { + fn from(tron_details: TronTxFeeDetails) -> Self { + TxFeeDetails::Tron(tron_details) + } +} + +impl From for TxFeeDetails { + fn from(utxo_details: UtxoFeeDetails) -> Self { + TxFeeDetails::Utxo(utxo_details) + } +} + +impl From for TxFeeDetails { + fn from(qrc20_details: Qrc20FeeDetails) -> Self { + TxFeeDetails::Qrc20(qrc20_details) + } +} + +impl From for TxFeeDetails { + fn from(sia_details: SiaFeeDetails) -> Self { + TxFeeDetails::Sia(sia_details) + } +} + +impl From for TxFeeDetails { + fn from(tendermint_details: TendermintFeeDetails) -> Self { + TxFeeDetails::Tendermint(tendermint_details) + } +} diff --git a/mm2src/coins/utxo.rs b/mm2src/coins/utxo.rs index eaee55c44b..89ef8c4bf8 100644 --- a/mm2src/coins/utxo.rs +++ b/mm2src/coins/utxo.rs @@ -41,6 +41,7 @@ pub mod utxo_hd_wallet; pub mod utxo_standard; pub mod utxo_tx_history_v2; pub mod utxo_withdraw; +#[cfg(feature = "utxo-walletconnect")] pub mod wallet_connect; use async_trait::async_trait; @@ -61,6 +62,7 @@ use futures::channel::mpsc::{Receiver as AsyncReceiver, Sender as AsyncSender}; use futures::compat::Future01CompatExt; use futures::lock::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard}; use futures01::Future; +use kdf_walletconnect::chain::WcChainId; use keys::bytes::Bytes; use keys::NetworkAddressPrefixes; use keys::Signature; @@ -602,6 +604,9 @@ pub struct UtxoCoinConf { pub checksum_type: ChecksumType, /// Fork id used in sighash pub fork_id: u32, + /// A CAIP-2 compliant chain ID. This is used to identify the UTXO chain in WalletConnect and other cross-chain protocols. + /// https://github.com/ChainAgnostic/CAIPs/blob/9516a2c0b26223d98a342938bf6d9ee59517f190/CAIPs/caip-4.md + pub chain_id: Option, /// Signature version pub signature_version: SignatureVersion, pub required_confirmations: AtomicU64, @@ -1060,7 +1065,7 @@ pub trait UtxoCommonOps: /// The method is expected to fail if [`UtxoCoinFields::priv_key_policy`] is [`PrivKeyPolicy::HardwareWallet`]. /// It's worth adding a method like `my_public_key_der_path` /// that takes a derivation path from which we derive the corresponding public key. - fn my_public_key(&self) -> Result<&Public, MmError>; + fn my_public_key(&self) -> Result>; /// Try to parse address from string using specified on asset enable format, /// and if it failed inform user that he used a wrong format. @@ -1087,7 +1092,7 @@ pub trait UtxoCommonOps: /// Generates a transaction spending P2SH vout (typically, with 0 index [`utxo_common::DEFAULT_SWAP_VOUT`]) of input.prev_transaction /// Works only if single signature is required! - async fn p2sh_spending_tx(&self, input: utxo_common::P2SHSpendingTxInput<'_>) -> Result; + async fn p2sh_spending_tx(&self, input: utxo_common::P2SHSpendingTxInput) -> Result; /// Loads verbose transactions from cache or requests it using RPC client. fn get_verbose_transactions_from_cache_or_rpc( @@ -1881,7 +1886,6 @@ where T: AsRef + UtxoTxGenerationOps + UtxoTxBroadcastOps, { let my_address = try_tx_s!(coin.as_ref().derivation_method.single_addr_or_err().await); - let key_pair = try_tx_s!(coin.as_ref().priv_key_policy.activated_key_or_err()); let mut builder = UtxoTxBuilder::new(coin) .await .add_available_inputs(unspents) @@ -1892,7 +1896,7 @@ where } let (unsigned, _) = try_tx_s!(builder.build().await); - let spent_unspents = unsigned + let spent_unspents: Vec<_> = unsigned .inputs .iter() .map(|input| UnspentInfo { @@ -1908,12 +1912,29 @@ where _ => coin.as_ref().conf.signature_version, }; - let signed = try_tx_s!(sign_tx( - unsigned, - key_pair, - signature_version, - coin.as_ref().conf.fork_id - )); + let signed = match coin.as_ref().priv_key_policy { + PrivKeyPolicy::Iguana(activated_key) | PrivKeyPolicy::HDWallet { activated_key, .. } => { + try_tx_s!(sign_tx( + unsigned, + &activated_key, + signature_version, + coin.as_ref().conf.fork_id + )) + }, + #[cfg(feature = "utxo-walletconnect")] + PrivKeyPolicy::WalletConnect { ref session_topic, .. } => { + try_tx_s!(wallet_connect::sign_p2pkh(coin, session_topic, &unsigned).await) + }, + #[cfg(not(feature = "utxo-walletconnect"))] + PrivKeyPolicy::WalletConnect { .. } => { + return Err(TransactionErr::Plain( + "WalletConnect signing requires utxo-walletconnect feature".to_string(), + )) + }, + PrivKeyPolicy::Trezor => return Err(TransactionErr::Plain("Can't sign tx with trezor".to_string())), + #[cfg(target_arch = "wasm32")] + PrivKeyPolicy::Metamask { .. } => return Err(TransactionErr::Plain("Can't sign tx with metamask".to_string())), + }; Ok((signed, spent_unspents)) } diff --git a/mm2src/coins/utxo/bch.rs b/mm2src/coins/utxo/bch.rs index 891b0391e3..6ff43fa904 100644 --- a/mm2src/coins/utxo/bch.rs +++ b/mm2src/coins/utxo/bch.rs @@ -812,7 +812,7 @@ impl UtxoCommonOps for BchCoin { utxo_common::denominate_satoshis(&self.utxo_arc, satoshi) } - fn my_public_key(&self) -> Result<&Public, MmError> { + fn my_public_key(&self) -> Result> { utxo_common::my_public_key(self.as_ref()) } @@ -844,7 +844,7 @@ impl UtxoCommonOps for BchCoin { utxo_common::get_mut_verbose_transaction_from_map_or_rpc(self, tx_hash, utxo_tx_map).await } - async fn p2sh_spending_tx(&self, input: utxo_common::P2SHSpendingTxInput<'_>) -> Result { + async fn p2sh_spending_tx(&self, input: utxo_common::P2SHSpendingTxInput) -> Result { utxo_common::p2sh_spending_tx(self, input).await } @@ -1019,12 +1019,7 @@ impl SwapOps for BchCoin { } #[inline] - async fn extract_secret( - &self, - secret_hash: &[u8], - spend_tx: &[u8], - _watcher_reward: bool, - ) -> Result<[u8; 32], String> { + async fn extract_secret(&self, secret_hash: &[u8], spend_tx: &[u8]) -> Result<[u8; 32], String> { utxo_common::extract_secret(secret_hash, spend_tx) } @@ -1048,7 +1043,7 @@ impl SwapOps for BchCoin { } fn derive_htlc_pubkey(&self, swap_unique_data: &[u8]) -> [u8; 33] { - utxo_common::derive_htlc_pubkey(self, swap_unique_data) + utxo_common::derive_htlc_pubkey(self.as_ref(), swap_unique_data) } #[inline] diff --git a/mm2src/coins/utxo/qtum.rs b/mm2src/coins/utxo/qtum.rs index da98e1c8de..e69e03ff57 100644 --- a/mm2src/coins/utxo/qtum.rs +++ b/mm2src/coins/utxo/qtum.rs @@ -424,7 +424,7 @@ impl UtxoCommonOps for QtumCoin { utxo_common::denominate_satoshis(&self.utxo_arc, satoshi) } - fn my_public_key(&self) -> Result<&Public, MmError> { + fn my_public_key(&self) -> Result> { utxo_common::my_public_key(self.as_ref()) } @@ -462,7 +462,7 @@ impl UtxoCommonOps for QtumCoin { utxo_common::get_mut_verbose_transaction_from_map_or_rpc(self, tx_hash, utxo_tx_map).await } - async fn p2sh_spending_tx(&self, input: utxo_common::P2SHSpendingTxInput<'_>) -> Result { + async fn p2sh_spending_tx(&self, input: utxo_common::P2SHSpendingTxInput) -> Result { utxo_common::p2sh_spending_tx(self, input).await } @@ -659,12 +659,7 @@ impl SwapOps for QtumCoin { } #[inline] - async fn extract_secret( - &self, - secret_hash: &[u8], - spend_tx: &[u8], - _watcher_reward: bool, - ) -> Result<[u8; 32], String> { + async fn extract_secret(&self, secret_hash: &[u8], spend_tx: &[u8]) -> Result<[u8; 32], String> { utxo_common::extract_secret(secret_hash, spend_tx) } @@ -688,7 +683,7 @@ impl SwapOps for QtumCoin { } fn derive_htlc_pubkey(&self, swap_unique_data: &[u8]) -> [u8; 33] { - utxo_common::derive_htlc_pubkey(self, swap_unique_data) + utxo_common::derive_htlc_pubkey(self.as_ref(), swap_unique_data) } #[inline] diff --git a/mm2src/coins/utxo/rpc_clients.rs b/mm2src/coins/utxo/rpc_clients.rs index 3a024c313f..beff7eb6ae 100644 --- a/mm2src/coins/utxo/rpc_clients.rs +++ b/mm2src/coins/utxo/rpc_clients.rs @@ -822,6 +822,7 @@ impl JsonRpcBatchClient for NativeClientImpl {} #[async_trait] #[cfg_attr(test, mockable)] impl UtxoRpcClientOps for NativeClient { + #[allow(clippy::result_large_err)] fn list_unspent(&self, address: &Address, decimals: u8) -> UtxoRpcFut> { let fut = self .list_unspent_impl(0, i32::MAX, vec![address.to_string()]) @@ -835,6 +836,7 @@ impl UtxoRpcClientOps for NativeClient { Box::new(fut) } + #[allow(clippy::result_large_err)] fn list_unspent_group(&self, addresses: Vec
, decimals: u8) -> UtxoRpcFut { let mut addresses_str = Vec::with_capacity(addresses.len()); let mut addresses_map = HashMap::with_capacity(addresses.len()); diff --git a/mm2src/coins/utxo/rpc_clients/electrum_rpc/client.rs b/mm2src/coins/utxo/rpc_clients/electrum_rpc/client.rs index 197e8ff426..f175107b1b 100644 --- a/mm2src/coins/utxo/rpc_clients/electrum_rpc/client.rs +++ b/mm2src/coins/utxo/rpc_clients/electrum_rpc/client.rs @@ -538,6 +538,7 @@ impl ElectrumClient { /// https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-scripthash-listunspent /// It can return duplicates sometimes: https://github.com/artemii235/SuperNET/issues/269 /// We should remove them to build valid transactions + #[allow(clippy::result_large_err)] pub fn scripthash_list_unspent(&self, hash: &str) -> RpcRes> { let request_fut = Box::new(rpc_func!(self, "blockchain.scripthash.listunspent", hash).and_then( move |unspents: Vec| { @@ -763,6 +764,7 @@ impl ElectrumClient { Ok((merkle_branch, header, height)) } + #[allow(clippy::result_large_err)] pub fn retrieve_headers_from( &self, server_address: &str, @@ -1105,6 +1107,7 @@ impl UtxoRpcClientOps for ElectrumClient { Box::new(fut.boxed().compat()) } + #[allow(clippy::result_large_err)] fn get_median_time_past(&self, starting_block: u64, count: NonZeroU64) -> UtxoRpcFut { let from = if starting_block <= count.get() { 0 diff --git a/mm2src/coins/utxo/slp.rs b/mm2src/coins/utxo/slp.rs index 63966089f5..2d616bcb57 100644 --- a/mm2src/coins/utxo/slp.rs +++ b/mm2src/coins/utxo/slp.rs @@ -10,7 +10,7 @@ use crate::tx_history_storage::{GetTxHistoryFilters, WalletId}; use crate::utxo::bch::BchCoin; use crate::utxo::bchd_grpc::{check_slp_transaction, validate_slp_utxos, ValidateSlpUtxosErr}; use crate::utxo::rpc_clients::{UnspentInfo, UtxoRpcClientEnum, UtxoRpcError, UtxoRpcResult}; -use crate::utxo::utxo_common::{self, big_decimal_from_sat_unsigned, payment_script, UtxoTxBuilder}; +use crate::utxo::utxo_common::{self, big_decimal_from_sat_unsigned, payment_script, UtxoTxBuilder, DEFAULT_SWAP_VIN}; use crate::utxo::{ generate_and_send_tx, sat_from_big_decimal, ActualFeeRate, BroadcastTxErr, FeePolicy, GenerateTxError, RecentlySpentOutPointsGuard, UtxoCoinConf, UtxoCoinFields, UtxoCommonOps, UtxoTx, UtxoTxBroadcastOps, @@ -581,7 +581,7 @@ impl SlpToken { let other_pub = Public::from_slice(other_pub)?; let my_public_key = self.platform_coin.my_public_key().map_mm_err()?; - let redeem_script = payment_script(time_lock, secret_hash, my_public_key, &other_pub); + let redeem_script = payment_script(time_lock, secret_hash, &my_public_key, &other_pub); let slp_amount = match slp_tx.transaction { SlpTransaction::Send { token_id, amounts } => { @@ -702,7 +702,7 @@ impl SlpToken { .map_mm_err()?; unsigned.lock_time = tx_locktime; - unsigned.inputs[0].sequence = input_sequence; + unsigned.inputs[DEFAULT_SWAP_VIN].sequence = input_sequence; let my_key_pair = self .platform_coin @@ -712,7 +712,7 @@ impl SlpToken { .map_mm_err()?; let signed_p2sh_input = p2sh_spend( &unsigned, - 0, + DEFAULT_SWAP_VIN, htlc_keypair, script_data, redeem_script, @@ -1494,12 +1494,7 @@ impl SwapOps for SlpToken { } #[inline] - async fn extract_secret( - &self, - secret_hash: &[u8], - spend_tx: &[u8], - _watcher_reward: bool, - ) -> Result<[u8; 32], String> { + async fn extract_secret(&self, secret_hash: &[u8], spend_tx: &[u8]) -> Result<[u8; 32], String> { utxo_common::extract_secret(secret_hash, spend_tx) } @@ -1516,7 +1511,7 @@ impl SwapOps for SlpToken { } fn derive_htlc_pubkey(&self, swap_unique_data: &[u8]) -> [u8; 33] { - utxo_common::derive_htlc_pubkey(self, swap_unique_data) + utxo_common::derive_htlc_pubkey(self.as_ref(), swap_unique_data) } #[inline] @@ -2162,7 +2157,7 @@ mod slp_tests { let other_pub = Public::from_slice(&other_pub).unwrap(); let my_public_key = bch.my_public_key().unwrap(); - let htlc_script = payment_script(1624547837, &secret_hash, &other_pub, my_public_key); + let htlc_script = payment_script(1624547837, &secret_hash, &other_pub, &my_public_key); let slp_send_op_return_out = slp_send_output(&token_id, &[1000]); @@ -2256,10 +2251,10 @@ mod slp_tests { // standard BCH validation should pass as the output itself is correct block_on(utxo_common::validate_payment( - bch.clone(), + bch, &deserialize(payment_tx.as_slice()).unwrap(), SLP_SWAP_VOUT, - my_pub, + &my_pub, &other_pub, SwapTxTypeWithSecretHash::TakerOrMakerPayment { maker_secret_hash: &secret_hash, diff --git a/mm2src/coins/utxo/utxo_builder/utxo_coin_builder.rs b/mm2src/coins/utxo/utxo_builder/utxo_coin_builder.rs index 356caeff9d..ee1de2346e 100644 --- a/mm2src/coins/utxo/utxo_builder/utxo_coin_builder.rs +++ b/mm2src/coins/utxo/utxo_builder/utxo_coin_builder.rs @@ -8,15 +8,15 @@ use crate::utxo::rpc_clients::{ use crate::utxo::tx_cache::{UtxoVerboseCacheOps, UtxoVerboseCacheShared}; use crate::utxo::utxo_block_header_storage::BlockHeaderStorage; use crate::utxo::utxo_builder::utxo_conf_builder::{UtxoConfBuilder, UtxoConfError, UtxoFeeConfig}; -use crate::utxo::wallet_connect::{get_pubkey_via_wallatconnect_signature, get_walletconnect_address}; +#[cfg(feature = "utxo-walletconnect")] +use crate::utxo::wallet_connect::{get_pubkey_via_walletconnect_signature, get_walletconnect_address}; use crate::utxo::{ output_script, ElectrumBuilderArgs, FeeRate, RecentlySpentOutPoints, UtxoCoinConf, UtxoCoinFields, UtxoHDWallet, UtxoRpcMode, UtxoSyncStatus, UtxoSyncStatusLoopHandle, UTXO_DUST_AMOUNT, }; use crate::{ - BlockchainNetwork, CoinProtocol, CoinTransportMetrics, DerivationMethod, HistorySyncState, IguanaPrivKey, - PrivKeyBuildPolicy, PrivKeyPolicy, PrivKeyPolicyNotAllowed, RpcClientType, SharableRpcTransportEventHandler, - UtxoActivationParams, + BlockchainNetwork, CoinTransportMetrics, DerivationMethod, HistorySyncState, IguanaPrivKey, PrivKeyBuildPolicy, + PrivKeyPolicy, PrivKeyPolicyNotAllowed, RpcClientType, SharableRpcTransportEventHandler, UtxoActivationParams, }; use async_trait::async_trait; use chain::TxHashAlgo; @@ -30,12 +30,14 @@ use derive_more::Display; use futures::channel::mpsc::{channel, Receiver as AsyncReceiver}; use futures::compat::Future01CompatExt; use futures::lock::Mutex as AsyncMutex; -use kdf_walletconnect::chain::WcChainId; +#[cfg(feature = "utxo-walletconnect")] use kdf_walletconnect::error::WalletConnectError; +#[cfg(feature = "utxo-walletconnect")] use kdf_walletconnect::{WalletConnectCtx, WcTopic}; pub use keys::{Address, AddressBuilder, AddressFormat as UtxoAddressFormat, KeyPair, Private, Public}; use mm2_core::mm_ctx::MmArc; use mm2_err_handle::prelude::*; +#[cfg(feature = "utxo-walletconnect")] use secp256k1::PublicKey; use serde_json::{self as json, Value as Json}; use serialization::ChainVariant; @@ -94,6 +96,7 @@ pub enum UtxoCoinBuildError { mode: String, }, InvalidPathToAddress(String), + #[cfg(feature = "utxo-walletconnect")] WalletConnectError(WalletConnectError), } @@ -146,6 +149,7 @@ impl From for UtxoCoinBuildError { } } +#[cfg(feature = "utxo-walletconnect")] impl From for UtxoCoinBuildError { fn from(e: WalletConnectError) -> Self { UtxoCoinBuildError::WalletConnectError(e) @@ -168,9 +172,14 @@ pub trait UtxoCoinBuilder: UtxoCoinBuilderCommonOps { build_utxo_fields_with_global_hd(self, global_hd_ctx).await }, PrivKeyBuildPolicy::Trezor => build_utxo_fields_with_trezor(self).await, + #[cfg(feature = "utxo-walletconnect")] PrivKeyBuildPolicy::WalletConnect { session_topic } => { build_utxo_fields_with_walletconnect(self, &session_topic).await }, + #[cfg(not(feature = "utxo-walletconnect"))] + PrivKeyBuildPolicy::WalletConnect { .. } => MmError::err(UtxoCoinBuildError::Internal( + "WalletConnect activation requires utxo-walletconnect feature".to_string(), + )), } } } @@ -280,6 +289,7 @@ where build_utxo_coin_fields_with_conf_and_policy(builder, conf, priv_key_policy, derivation_method).await } +#[cfg(feature = "utxo-walletconnect")] async fn build_utxo_fields_with_walletconnect( builder: &Builder, session_topic: &WcTopic, @@ -291,7 +301,11 @@ where .build() .map_mm_err()?; - let chain_id = builder.wallet_connect_chain_id()?; + let chain_id = conf.chain_id.clone().ok_or_else(|| { + UtxoCoinBuildError::ConfError(UtxoConfError::InvalidProtocolData( + "chain_id is not set correctly in coins config".to_string(), + )) + })?; let full_derivation_path = builder.full_derivation_path()?; let wc_ctx = WalletConnectCtx::from_ctx(builder.ctx()).map_mm_err()?; @@ -306,7 +320,7 @@ where let sign_message_prefix = conf.sign_message_prefix.as_ref().ok_or_else(|| { UtxoCoinBuildError::Internal("sign_message_prefix is not set in coins config".to_string()) })?; - get_pubkey_via_wallatconnect_signature(&wc_ctx, session_topic, &chain_id, &address, sign_message_prefix) + get_pubkey_via_walletconnect_signature(&wc_ctx, session_topic, &chain_id, &address, sign_message_prefix) .await .map_mm_err()? }, @@ -591,30 +605,6 @@ pub trait UtxoCoinBuilderCommonOps { Ok(BlockchainNetwork::Mainnet) } - /// Returns WcChainId for this coin. Parsed from the coin config. - fn wallet_connect_chain_id(&self) -> UtxoCoinBuildResult { - let protocol: CoinProtocol = json::from_value(self.conf()["protocol"].clone()).map_to_mm(|e| { - UtxoCoinBuildError::ConfError(UtxoConfError::InvalidProtocolData(format!( - "Couldn't parse protocol info: {e}" - ))) - })?; - - if let CoinProtocol::UTXO(utxo_info) = protocol { - let utxo_info = utxo_info.ok_or_else(|| { - WalletConnectError::InvalidChainId(format!( - "coin={} doesn't have chain_id (bip122 standard) set in coin config which is required for WalletConnect", - self.ticker() - )) - })?; - let chain_id = WcChainId::try_from_str(&utxo_info.chain_id).map_mm_err()?; - Ok(chain_id) - } else { - MmError::err(UtxoCoinBuildError::ConfError(UtxoConfError::InvalidProtocolData( - format!("Expected UTXO protocol, got: {protocol:?}"), - ))) - } - } - /// Constructs the full HD derivation path from the coin config and the activation params partial paths. fn full_derivation_path(&self) -> UtxoCoinBuildResult { let path_purpose_to_coin = self.conf()["derivation_path"].as_str().ok_or_else(|| { diff --git a/mm2src/coins/utxo/utxo_builder/utxo_conf_builder.rs b/mm2src/coins/utxo/utxo_builder/utxo_conf_builder.rs index 07fb224a18..dbd2db698d 100644 --- a/mm2src/coins/utxo/utxo_builder/utxo_conf_builder.rs +++ b/mm2src/coins/utxo/utxo_builder/utxo_conf_builder.rs @@ -3,10 +3,11 @@ use crate::utxo::{ parse_hex_encoded_u32, UtxoCoinConf, DEFAULT_DYNAMIC_FEE_VOLATILITY_PERCENT, KMD_MTP_BLOCK_COUNT, MATURE_CONFIRMATIONS_DEFAULT, }; -use crate::UtxoActivationParams; +use crate::{CoinProtocol, UtxoActivationParams}; use bitcrypto::ChecksumType; use crypto::{Bip32Error, HDPathToCoin}; use derive_more::Display; +use kdf_walletconnect::chain::WcChainId; pub use keys::AddressFormat as UtxoAddressFormat; use keys::NetworkAddressPrefixes; use mm2_err_handle::prelude::*; @@ -120,6 +121,7 @@ impl<'a> UtxoConfBuilder<'a> { let derivation_path = self.derivation_path()?; let avg_blocktime = self.avg_blocktime(); let spv_conf = self.spv_conf()?; + let chain_id = self.bip122_chain_id(); let chain_variant = self.chain_variant()?; Ok(UtxoCoinConf { @@ -143,6 +145,7 @@ impl<'a> UtxoConfBuilder<'a> { checksum_type, signature_version, fork_id, + chain_id, required_confirmations: required_confirmations.into(), force_min_relay_fee, mtp_block_count, @@ -283,6 +286,14 @@ impl<'a> UtxoConfBuilder<'a> { fork_id } + fn bip122_chain_id(&self) -> Option { + if let Ok(CoinProtocol::UTXO(Some(utxo_info))) = json::from_value(self.conf["protocol"].clone()) { + WcChainId::try_from_str(&utxo_info.chain_id).ok() + } else { + None + } + } + fn required_confirmations(&self) -> u64 { // param from request should override the config self.params diff --git a/mm2src/coins/utxo/utxo_common.rs b/mm2src/coins/utxo/utxo_common.rs index 3bd6dadb7c..8b718d7b14 100644 --- a/mm2src/coins/utxo/utxo_common.rs +++ b/mm2src/coins/utxo/utxo_common.rs @@ -46,6 +46,8 @@ use futures::compat::Future01CompatExt; use futures::future::{FutureExt, TryFutureExt}; use futures01::future::Either; use itertools::Itertools; +#[cfg(feature = "utxo-walletconnect")] +use kdf_walletconnect::WcTopic; use keys::bytes::Bytes; #[cfg(test)] use keys::prefixes::{KMD_PREFIXES, T_QTUM_PREFIXES}; @@ -57,7 +59,6 @@ use mm2_core::mm_ctx::MmArc; use mm2_err_handle::prelude::*; use mm2_number::bigdecimal_custom::CheckedDivision; use mm2_number::{BigDecimal, MmNumber}; -use primitives::hash::H512; use rpc::v1::types::{Bytes as BytesJson, ToTxHash, TransactionInputEnum, H256 as H256Json}; #[cfg(test)] use rpc_clients::NativeClientImpl; @@ -75,7 +76,7 @@ use utxo_common_tests::{utxo_coin_fields_for_test, utxo_coin_from_fields}; use utxo_signer::with_key_pair::{ calc_and_sign_sighash, p2sh_spend, signature_hash_to_sign, SIGHASH_ALL, SIGHASH_SINGLE, }; -use utxo_signer::UtxoSignerOps; +use utxo_signer::{complete_tx, UtxoSignerOps}; pub mod utxo_tx_history_v2_common; @@ -392,22 +393,20 @@ pub fn address_from_str_unchecked(coin: &UtxoCoinFields, address: &str) -> MmRes MmError::err(AddrFromStrError::CannotDetermineFormat(errors)) } -pub fn my_public_key(coin: &UtxoCoinFields) -> Result<&Public, MmError> { +pub fn my_public_key(coin: &UtxoCoinFields) -> Result> { match coin.priv_key_policy { - PrivKeyPolicy::Iguana(ref key_pair) => Ok(key_pair.public()), + PrivKeyPolicy::Iguana(ref key_pair) => Ok(*key_pair.public()), PrivKeyPolicy::HDWallet { activated_key: ref activated_key_pair, .. - } => Ok(activated_key_pair.public()), + } => Ok(*activated_key_pair.public()), // Hardware Wallets requires BIP32/BIP44 derivation path to extract a public key. PrivKeyPolicy::Trezor => MmError::err(UnexpectedDerivationMethod::Trezor), #[cfg(target_arch = "wasm32")] PrivKeyPolicy::Metamask(_) => MmError::err(UnexpectedDerivationMethod::UnsupportedError( "`PrivKeyPolicy::Metamask` is not supported in this context".to_string(), )), - PrivKeyPolicy::WalletConnect { .. } => MmError::err(UnexpectedDerivationMethod::UnsupportedError( - "`PrivKeyPolicy::WalletConnect` is not supported in this context".to_string(), - )), + PrivKeyPolicy::WalletConnect { public_key, .. } => Ok(Public::Compressed(public_key.0.into())), } } @@ -912,14 +911,42 @@ fn get_tx_fee_with_relay_fee(fee_rate: &ActualFeeRate, tx_size: u64, min_relay_f tx_fee } -pub struct P2SHSpendingTxInput<'a> { +pub enum P2SHSigner { + KeyPair(KeyPair), + #[cfg(feature = "utxo-walletconnect")] + WalletConnect(WcTopic), +} + +impl P2SHSigner { + fn try_from_coin(coin: &Coin, swap_unique_data: &[u8]) -> Result + where + Coin: UtxoCommonOps + SwapOps, + { + match &coin.as_ref().priv_key_policy { + PrivKeyPolicy::Iguana { .. } | PrivKeyPolicy::HDWallet { .. } => { + Ok(P2SHSigner::KeyPair(coin.derive_htlc_key_pair(swap_unique_data))) + }, + PrivKeyPolicy::Trezor => Err("P2SH signing is not supported for Trezor".to_string()), + #[cfg(target_arch = "wasm32")] + PrivKeyPolicy::Metamask(_) => Err("P2SH signing is not supported for Metamask".to_string()), + #[cfg(feature = "utxo-walletconnect")] + PrivKeyPolicy::WalletConnect { session_topic, .. } => Ok(P2SHSigner::WalletConnect(session_topic.clone())), + #[cfg(not(feature = "utxo-walletconnect"))] + PrivKeyPolicy::WalletConnect { .. } => { + Err("P2SH WalletConnect signing requires utxo-walletconnect feature".to_string()) + }, + } + } +} + +pub struct P2SHSpendingTxInput { prev_transaction: UtxoTx, redeem_script: Bytes, outputs: Vec, script_data: Script, sequence: u32, lock_time: u32, - keypair: &'a KeyPair, + signer: P2SHSigner, } enum LocktimeSetting { @@ -994,7 +1021,7 @@ async fn p2sh_spending_tx_preimage( }) } -pub async fn p2sh_spending_tx(coin: &T, input: P2SHSpendingTxInput<'_>) -> Result { +pub async fn p2sh_spending_tx(coin: &T, input: P2SHSpendingTxInput) -> Result { let unsigned = try_s!( p2sh_spending_tx_preimage( coin, @@ -1006,37 +1033,32 @@ pub async fn p2sh_spending_tx(coin: &T, input: P2SHSpendingTxI ) .await ); - let signed_input = try_s!(p2sh_spend( - &unsigned, - DEFAULT_SWAP_VOUT, - input.keypair, - input.script_data, - input.redeem_script.into(), - coin.as_ref().conf.signature_version, - coin.as_ref().conf.fork_id - )); - Ok(UtxoTx { - version: unsigned.version, - n_time: unsigned.n_time, - overwintered: unsigned.overwintered, - lock_time: unsigned.lock_time, - inputs: vec![signed_input], - outputs: unsigned.outputs, - expiry_height: unsigned.expiry_height, - join_splits: vec![], - shielded_spends: vec![], - shielded_outputs: vec![], - value_balance: 0, - version_group_id: coin.as_ref().conf.version_group_id, - binding_sig: H512::default(), - join_split_sig: H512::default(), - join_split_pubkey: H256::default(), - zcash: coin.as_ref().conf.zcash, - posv: coin.as_ref().conf.is_posv, - str_d_zeel: unsigned.str_d_zeel, - tx_hash_algo: unsigned.hash_algo.into(), - v_extra_payload: None, - }) + + match input.signer { + P2SHSigner::KeyPair(key_pair) => { + let signed_input = try_s!(p2sh_spend( + &unsigned, + DEFAULT_SWAP_VIN, + &key_pair, + input.script_data, + input.redeem_script.into(), + coin.as_ref().conf.signature_version, + coin.as_ref().conf.fork_id + )); + Ok(complete_tx(unsigned, vec![signed_input])) + }, + #[cfg(feature = "utxo-walletconnect")] + P2SHSigner::WalletConnect(session_topic) => wallet_connect::sign_p2sh( + coin, + &session_topic, + &unsigned, + input.prev_transaction, + input.redeem_script, + input.script_data.into(), + ) + .await + .map_err(|e| format!("WalletConnect P2SH signing error: {e}")), + } } type GenPreimageResInner = MmResult; @@ -1638,14 +1660,14 @@ pub fn send_maker_payment(coin: T, args: SendPaymentArgs) -> TransactionFut where T: UtxoCommonOps + GetUtxoListOps + SwapOps, { - let maker_htlc_key_pair = coin.derive_htlc_key_pair(args.swap_unique_data); + let maker_pubkey = coin.derive_htlc_pubkey(args.swap_unique_data); let SwapPaymentOutputsResult { payment_address, outputs, } = try_tx_fus!(generate_swap_payment_outputs( &coin, try_tx_fus!(args.time_lock.try_into()), - maker_htlc_key_pair.public_slice(), + &try_tx_fus!(Public::from_slice(&maker_pubkey)), args.other_pubkey, args.amount, SwapTxTypeWithSecretHash::TakerOrMakerPayment { @@ -1676,14 +1698,14 @@ where None => args.amount, }; - let taker_htlc_key_pair = coin.derive_htlc_key_pair(args.swap_unique_data); + let taker_pubkey = coin.derive_htlc_pubkey(args.swap_unique_data); let SwapPaymentOutputsResult { payment_address, outputs, } = try_tx_fus!(generate_swap_payment_outputs( &coin, try_tx_fus!(args.time_lock.try_into()), - taker_htlc_key_pair.public_slice(), + &try_tx_fus!(Public::from_slice(&taker_pubkey)), args.other_pubkey, total_amount, SwapTxTypeWithSecretHash::TakerOrMakerPayment { @@ -1720,7 +1742,7 @@ pub async fn send_maker_spends_taker_payment( drop_mutability!(prev_transaction); let payment_value = try_tx_s!(prev_transaction.first_output()).value; - let key_pair = coin.derive_htlc_key_pair(args.swap_unique_data); + let pubkey = coin.derive_htlc_pubkey(args.swap_unique_data); let script_data = Builder::default() .push_data(args.secret) .push_opcode(Opcode::OP_0) @@ -1731,7 +1753,7 @@ pub async fn send_maker_spends_taker_payment( time_lock, args.secret_hash, &try_tx_s!(Public::from_slice(args.other_pubkey)), - key_pair.public(), + &try_tx_s!(Public::from_slice(&pubkey)), ) .into(); let my_address = try_tx_s!(coin.as_ref().derivation_method.single_addr_or_err().await); @@ -1752,6 +1774,9 @@ pub async fn send_maker_spends_taker_payment( script_pubkey, }; + let signer = P2SHSigner::try_from_coin(&coin, args.swap_unique_data) + .map_err(|e| TransactionErr::Plain(ERRL!("Failed to create P2SHSigner: {}", e)))?; + let input = P2SHSpendingTxInput { prev_transaction, redeem_script, @@ -1759,7 +1784,7 @@ pub async fn send_maker_spends_taker_payment( script_data, sequence: SEQUENCE_FINAL, lock_time: time_lock, - keypair: &key_pair, + signer, }; let transaction = try_tx_s!(coin.p2sh_spending_tx(input).await); @@ -1873,7 +1898,7 @@ pub fn create_maker_payment_spend_preimage( script_data, sequence: SEQUENCE_FINAL, lock_time: time_lock, - keypair: &key_pair, + signer: P2SHSigner::KeyPair(key_pair), }; let transaction = try_tx_s!(coin.p2sh_spending_tx(input).await); @@ -1937,7 +1962,7 @@ pub fn create_taker_payment_refund_preimage( script_data, sequence: SEQUENCE_FINAL - 1, lock_time: time_lock, - keypair: &key_pair, + signer: P2SHSigner::KeyPair(key_pair), }; let transaction = try_tx_s!(coin.p2sh_spending_tx(input).await); @@ -1960,7 +1985,7 @@ pub async fn send_taker_spends_maker_payment( drop_mutability!(prev_transaction); let payment_value = try_tx_s!(prev_transaction.first_output()).value; - let key_pair = coin.derive_htlc_key_pair(args.swap_unique_data); + let pubkey = coin.derive_htlc_pubkey(args.swap_unique_data); let script_data = Builder::default() .push_data(args.secret) @@ -1972,7 +1997,7 @@ pub async fn send_taker_spends_maker_payment( time_lock, args.secret_hash, &try_tx_s!(Public::from_slice(args.other_pubkey)), - key_pair.public(), + &try_tx_s!(Public::from_slice(&pubkey)), ) .into(); @@ -1994,6 +2019,9 @@ pub async fn send_taker_spends_maker_payment( script_pubkey, }; + let signer = P2SHSigner::try_from_coin(&coin, args.swap_unique_data) + .map_err(|e| TransactionErr::Plain(ERRL!("Failed to create P2SHSigner: {}", e)))?; + let input = P2SHSpendingTxInput { prev_transaction, redeem_script, @@ -2001,7 +2029,7 @@ pub async fn send_taker_spends_maker_payment( script_data, sequence: SEQUENCE_FINAL, lock_time: time_lock, - keypair: &key_pair, + signer, }; let transaction = try_tx_s!(coin.p2sh_spending_tx(input).await); @@ -2028,13 +2056,13 @@ pub async fn refund_htlc_payment( let payment_value = try_tx_s!(prev_transaction.first_output()).value; let other_public = try_tx_s!(Public::from_slice(args.other_pubkey)); - let key_pair = coin.derive_htlc_key_pair(args.swap_unique_data); + let pubkey = coin.derive_htlc_pubkey(args.swap_unique_data); let script_data = Builder::default().push_opcode(Opcode::OP_1).into_script(); let time_lock = try_tx_s!(args.time_lock.try_into()); let redeem_script = args .tx_type_with_secret_hash - .redeem_script(time_lock, key_pair.public(), &other_public) + .redeem_script(time_lock, &try_tx_s!(Public::from_slice(&pubkey)), &other_public) .into(); let fee = try_tx_s!( coin.get_htlc_spend_fee(DEFAULT_SWAP_TX_SPEND_SIZE, &FeeApproxStage::WithoutApprox) @@ -2053,6 +2081,9 @@ pub async fn refund_htlc_payment( script_pubkey, }; + let signer = P2SHSigner::try_from_coin(&coin, args.swap_unique_data) + .map_err(|e| TransactionErr::Plain(ERRL!("Failed to create P2SHSigner: {}", e)))?; + let input = P2SHSpendingTxInput { prev_transaction, redeem_script, @@ -2060,7 +2091,7 @@ pub async fn refund_htlc_payment( script_data, sequence: SEQUENCE_FINAL - 1, lock_time: time_lock, - keypair: &key_pair, + signer, }; let transaction = try_tx_s!(coin.p2sh_spending_tx(input).await); @@ -2586,7 +2617,8 @@ pub async fn validate_maker_payment( })?; tx.tx_hash_algo = coin.as_ref().tx_hash_algo; - let htlc_keypair = coin.derive_htlc_key_pair(&input.unique_swap_data); + let our_pub = Public::from_slice(&coin.derive_htlc_pubkey(&input.unique_swap_data)) + .map_to_mm(|e| ValidatePaymentError::InternalError(format!("Failed to derive HTLC pubkey: {e}")))?; let other_pub = Public::from_slice(&input.other_pub) .map_to_mm(|err| ValidatePaymentError::InvalidParameter(err.to_string()))?; let time_lock = input @@ -2598,7 +2630,7 @@ pub async fn validate_maker_payment( &tx, DEFAULT_SWAP_VOUT, &other_pub, - htlc_keypair.public(), + &our_pub, SwapTxTypeWithSecretHash::TakerOrMakerPayment { maker_secret_hash: &input.secret_hash, }, @@ -2718,7 +2750,8 @@ pub async fn validate_taker_payment( })?; tx.tx_hash_algo = coin.as_ref().tx_hash_algo; - let htlc_keypair = coin.derive_htlc_key_pair(&input.unique_swap_data); + let our_pub = Public::from_slice(&coin.derive_htlc_pubkey(&input.unique_swap_data)) + .map_to_mm(|e| ValidatePaymentError::InternalError(format!("Failed to derive HTLC pubkey: {e}")))?; let other_pub = Public::from_slice(&input.other_pub) .map_to_mm(|err| ValidatePaymentError::InvalidParameter(err.to_string()))?; let time_lock = input @@ -2730,7 +2763,7 @@ pub async fn validate_taker_payment( &tx, DEFAULT_SWAP_VOUT, &other_pub, - htlc_keypair.public(), + &our_pub, SwapTxTypeWithSecretHash::TakerOrMakerPayment { maker_secret_hash: &input.secret_hash, }, @@ -2791,11 +2824,11 @@ pub fn check_if_my_payment_sent( secret_hash: &[u8], swap_unique_data: &[u8], ) -> Box, Error = String> + Send> { - let my_htlc_keypair = coin.derive_htlc_key_pair(swap_unique_data); + let my_pub = coin.derive_htlc_pubkey(swap_unique_data); let script = payment_script( time_lock, secret_hash, - my_htlc_keypair.public(), + &try_fus!(Public::from_slice(&my_pub)), &try_fus!(Public::from_slice(other_pub)), ); let hash = dhash160(&script); @@ -2882,7 +2915,7 @@ pub async fn search_for_swap_tx_spend_my + SwapOps>( search_for_swap_output_spend( coin.as_ref(), try_s!(input.time_lock.try_into()), - coin.derive_htlc_key_pair(input.swap_unique_data).public(), + &try_s!(Public::from_slice(&coin.derive_htlc_pubkey(input.swap_unique_data))), &try_s!(Public::from_slice(input.other_pub)), input.secret_hash, input.tx, @@ -2901,7 +2934,7 @@ pub async fn search_for_swap_tx_spend_other + SwapOps>( coin.as_ref(), try_s!(input.time_lock.try_into()), &try_s!(Public::from_slice(input.other_pub)), - coin.derive_htlc_key_pair(input.swap_unique_data).public(), + &try_s!(Public::from_slice(&coin.derive_htlc_pubkey(input.swap_unique_data))), input.secret_hash, input.tx, output_index, @@ -3452,9 +3485,12 @@ pub fn is_asset_chain(coin: &UtxoCoinFields) -> bool { coin.conf.asset_chain } +/// Returns whether DEX fee should be split with burn address. +/// Currently disabled - all fees go to DEX fee address. +// TODO: If we ever change this back to true, we need to check negotiation version was added pub const fn should_burn_dex_fee() -> bool { false -} // TODO: fix back to true when negotiation version added +} pub async fn get_raw_transaction(coin: &UtxoCoinFields, req: RawTransactionRequest) -> RawTransactionResult { let hash = H256Json::from_str(&req.tx_hash).map_to_mm(|e| RawTransactionError::InvalidHashError(e.to_string()))?; @@ -3542,7 +3578,7 @@ pub fn get_withdraw_iguana_sender( .mm_err(|e| WithdrawError::InternalError(e.to_string()))?; Ok(WithdrawSenderAddress { address: my_address.clone(), - pubkey: *pubkey, + pubkey, derivation_path: None, }) } @@ -4547,6 +4583,7 @@ where /// [`GetUtxoMapOps::get_mature_unspent_ordered_map`] implementation. /// Returns available mature and immature unspents in ascending order for every given `addresses` /// + `RecentlySpentOutPoints` MutexGuard for further interaction (e.g. to add new transaction to it). +#[allow(clippy::result_large_err)] pub async fn get_mature_unspent_ordered_map( coin: &T, addresses: Vec
, @@ -5274,12 +5311,18 @@ pub fn derive_htlc_key_pair(coin: &UtxoCoinFields, _swap_unique_data: &[u8]) -> } #[inline] -pub fn derive_htlc_pubkey(coin: &dyn SwapOps, swap_unique_data: &[u8]) -> [u8; 33] { - coin.derive_htlc_key_pair(swap_unique_data) - .public_slice() - .to_vec() - .try_into() - .expect("valid pubkey length") +pub fn derive_htlc_pubkey(coin: &UtxoCoinFields, swap_unique_data: &[u8]) -> [u8; 33] { + match coin.priv_key_policy { + PrivKeyPolicy::WalletConnect { public_key, .. } => public_key.0, + PrivKeyPolicy::HDWallet { .. } | PrivKeyPolicy::Iguana { .. } => derive_htlc_key_pair(coin, swap_unique_data) + .public_slice() + .to_vec() + .try_into() + .expect("valid pubkey length"), + PrivKeyPolicy::Trezor => panic!("`PrivKeyPolicy::Trezor` is not supported for UTXO coins"), + #[cfg(target_arch = "wasm32")] + PrivKeyPolicy::Metamask(_) => panic!("`PrivKeyPolicy::Metamask` is not supported for UTXO coins"), + } } pub fn validate_other_pubkey(raw_pubkey: &[u8]) -> MmResult<(), ValidateOtherPubKeyErr> { @@ -5313,7 +5356,7 @@ pub async fn send_taker_funding(coin: T, args: SendTakerFundingArgs<'_>) -> R where T: UtxoCommonOps + GetUtxoListOps + SwapOps, { - let taker_htlc_key_pair = coin.derive_htlc_key_pair(args.swap_unique_data); + let taker_pub = coin.derive_htlc_pubkey(args.swap_unique_data); let total_amount = &args.dex_fee.total_spend_amount().to_decimal() + &args.premium_amount + &args.trading_amount; let SwapPaymentOutputsResult { @@ -5322,7 +5365,7 @@ where } = try_tx_s!(generate_swap_payment_outputs( &coin, try_tx_s!(args.funding_time_lock.try_into()), - taker_htlc_key_pair.public_slice(), + &try_tx_s!(Public::from_slice(&taker_pub)), args.maker_pub, total_amount, SwapTxTypeWithSecretHash::TakerFunding { @@ -5390,7 +5433,7 @@ where script_data, sequence: SEQUENCE_FINAL, lock_time: time_lock, - keypair: &key_pair, + signer: P2SHSigner::KeyPair(key_pair), }; let transaction = try_tx_s!(coin.p2sh_spending_tx(input).await); @@ -5405,7 +5448,8 @@ pub async fn validate_taker_funding(coin: &T, args: ValidateTakerFundingArgs< where T: UtxoCommonOps + SwapOps, { - let maker_htlc_key_pair = coin.derive_htlc_key_pair(args.swap_unique_data); + let maker_pub = Public::from_slice(&coin.derive_htlc_pubkey(args.swap_unique_data)) + .map_to_mm(|e| ValidateSwapV2TxError::Internal(format!("Failed to derive maker public key: {e}")))?; let total_expected_amount = &args.dex_fee.total_spend_amount().to_decimal() + &args.premium_amount + &args.trading_amount; @@ -5416,12 +5460,8 @@ where .try_into() .map_to_mm(|e: TryFromIntError| ValidateSwapV2TxError::Overflow(e.to_string()))?; - let redeem_script = swap_proto_v2_scripts::taker_funding_script( - time_lock, - args.taker_secret_hash, - args.taker_pub, - maker_htlc_key_pair.public(), - ); + let redeem_script = + swap_proto_v2_scripts::taker_funding_script(time_lock, args.taker_secret_hash, args.taker_pub, &maker_pub); let expected_output = TransactionOutput { value: expected_amount_sat, script_pubkey: Builder::build_p2sh(&AddressHashEnum::AddressHash(dhash160(&redeem_script))).into(), @@ -5464,7 +5504,7 @@ pub async fn send_maker_payment_v2(coin: T, args: SendMakerPaymentArgs<'_, T> where T: UtxoCommonOps + GetUtxoListOps + SwapOps, { - let maker_htlc_key_pair = coin.derive_htlc_key_pair(args.swap_unique_data); + let maker_pubkey = coin.derive_htlc_pubkey(args.swap_unique_data); let SwapPaymentOutputsResult { payment_address, @@ -5472,7 +5512,7 @@ where } = try_tx_s!(generate_swap_payment_outputs( &coin, try_tx_s!(args.time_lock.try_into()), - maker_htlc_key_pair.public_slice(), + &try_tx_s!(Public::from_slice(&maker_pubkey)), args.taker_pub, args.amount, SwapTxTypeWithSecretHash::MakerPaymentV2 { @@ -5564,7 +5604,7 @@ pub async fn spend_maker_payment_v2( script_data, sequence: SEQUENCE_FINAL, lock_time: time_lock, - keypair: &key_pair, + signer: P2SHSigner::KeyPair(key_pair), }; let transaction = try_tx_s!(coin.p2sh_spending_tx(input).await); @@ -5625,7 +5665,7 @@ where script_data, sequence: SEQUENCE_FINAL, lock_time: time_lock, - keypair: &key_pair, + signer: P2SHSigner::KeyPair(key_pair), }; let transaction = try_tx_s!(coin.p2sh_spending_tx(input).await); diff --git a/mm2src/coins/utxo/utxo_common_tests.rs b/mm2src/coins/utxo/utxo_common_tests.rs index 3c9af239bb..1f02c37ae4 100644 --- a/mm2src/coins/utxo/utxo_common_tests.rs +++ b/mm2src/coins/utxo/utxo_common_tests.rs @@ -127,6 +127,7 @@ pub(super) fn utxo_coin_fields_for_test( zcash: true, checksum_type, fork_id: 0, + chain_id: None, signature_version: SignatureVersion::Base, required_confirmations: 1.into(), force_min_relay_fee: false, diff --git a/mm2src/coins/utxo/utxo_standard.rs b/mm2src/coins/utxo/utxo_standard.rs index fa7d621e3d..789d172a8c 100644 --- a/mm2src/coins/utxo/utxo_standard.rs +++ b/mm2src/coins/utxo/utxo_standard.rs @@ -210,7 +210,7 @@ impl UtxoCommonOps for UtxoStandardCoin { utxo_common::denominate_satoshis(&self.utxo_arc, satoshi) } - fn my_public_key(&self) -> Result<&Public, MmError> { + fn my_public_key(&self) -> Result> { utxo_common::my_public_key(self.as_ref()) } @@ -242,7 +242,7 @@ impl UtxoCommonOps for UtxoStandardCoin { utxo_common::get_mut_verbose_transaction_from_map_or_rpc(self, tx_hash, utxo_tx_map).await } - async fn p2sh_spending_tx(&self, input: utxo_common::P2SHSpendingTxInput<'_>) -> Result { + async fn p2sh_spending_tx(&self, input: utxo_common::P2SHSpendingTxInput) -> Result { utxo_common::p2sh_spending_tx(self, input).await } @@ -440,12 +440,7 @@ impl SwapOps for UtxoStandardCoin { } #[inline] - async fn extract_secret( - &self, - secret_hash: &[u8], - spend_tx: &[u8], - _watcher_reward: bool, - ) -> Result<[u8; 32], String> { + async fn extract_secret(&self, secret_hash: &[u8], spend_tx: &[u8]) -> Result<[u8; 32], String> { utxo_common::extract_secret(secret_hash, spend_tx) } @@ -469,7 +464,7 @@ impl SwapOps for UtxoStandardCoin { } fn derive_htlc_pubkey(&self, swap_unique_data: &[u8]) -> [u8; 33] { - utxo_common::derive_htlc_pubkey(self, swap_unique_data) + utxo_common::derive_htlc_pubkey(self.as_ref(), swap_unique_data) } #[inline] @@ -478,7 +473,10 @@ impl SwapOps for UtxoStandardCoin { } fn is_supported_by_watchers(&self) -> bool { - true + // Since watcher support require signing the watcher message with the same private key used in the swap, + // we disable watcher support for private key policies that don't give us access to the private key. + // TODO: Enable watcher support for WalletConnect by asking WalletConnect to sign the watcher message for us. + self.as_ref().priv_key_policy.is_internal() } } @@ -964,7 +962,9 @@ impl MarketCoinOps for UtxoStandardCoin { } fn should_burn_directly(&self) -> bool { - &self.utxo_arc.conf.ticker == "KMD" + // &self.utxo_arc.conf.ticker == "KMD" + // Burn disabled - all fees go to DEX fee address + false } fn should_burn_dex_fee(&self) -> bool { diff --git a/mm2src/coins/utxo/utxo_tests.rs b/mm2src/coins/utxo/utxo_tests.rs index 2ec224e48e..8622149ad8 100644 --- a/mm2src/coins/utxo/utxo_tests.rs +++ b/mm2src/coins/utxo/utxo_tests.rs @@ -38,8 +38,11 @@ use crate::{ #[cfg(not(target_arch = "wasm32"))] use crate::{WaitForHTLCTxSpendArgs, WithdrawFee}; use chain::{BlockHeader, BlockHeaderBits, OutPoint}; +use common::custom_futures::repeatable::{Ready, Retry}; use common::executor::Timer; -use common::{block_on, block_on_f01, wait_until_sec, OrdRange, PagingOptionsEnum, DEX_FEE_ADDR_RAW_PUBKEY}; +use common::{ + block_on, block_on_f01, repeatable, wait_until_sec, OrdRange, PagingOptionsEnum, DEX_FEE_ADDR_RAW_PUBKEY, +}; use crypto::{privkey::key_pair_from_seed, Bip44Chain, HDPathToAccount, RpcDerivationPath, Secp256k1Secret}; #[cfg(not(target_arch = "wasm32"))] use db_common::sqlite::rusqlite::Connection; @@ -51,6 +54,7 @@ use mm2_core::mm_ctx::MmCtxBuilder; use mm2_number::bigdecimal::{BigDecimal, Signed}; use mm2_number::MmNumber; use mm2_test_helpers::electrums::doc_electrums; +use mm2_test_helpers::for_tests::DEX_FEE_ADDR_RAW_PUBKEY_LEGACY; use mm2_test_helpers::for_tests::{ electrum_servers_rpc, mm_ctx_with_custom_db, DOC_ELECTRUM_ADDRS, MARTY_ELECTRUM_ADDRS, T_BCH_ELECTRUMS, }; @@ -65,6 +69,7 @@ use std::convert::TryFrom; use std::iter; use std::num::NonZeroUsize; use std::str::FromStr; +use std::time::Duration; #[cfg(test)] use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; @@ -168,7 +173,7 @@ fn test_extract_secret() { let expected_secret = <[u8; 32]>::from_hex("5c62072b57b6473aeee6d35270c8b56d86975e6d6d4245b25425d771239fae32").unwrap(); let secret_hash = &*dhash160(&expected_secret); - let secret = block_on(coin.extract_secret(secret_hash, &tx_hex, false)).unwrap(); + let secret = block_on(coin.extract_secret(secret_hash, &tx_hex)).unwrap(); assert_eq!(secret, expected_secret); } @@ -178,10 +183,11 @@ fn test_send_maker_spends_taker_payment_recoverable_tx() { let coin = utxo_coin_for_test(client.into(), None, false); let tx_hex = hex::decode("0100000001de7aa8d29524906b2b54ee2e0281f3607f75662cbc9080df81d1047b78e21dbc00000000d7473044022079b6c50820040b1fbbe9251ced32ab334d33830f6f8d0bf0a40c7f1336b67d5b0220142ccf723ddabb34e542ed65c395abc1fbf5b6c3e730396f15d25c49b668a1a401209da937e5609680cb30bff4a7661364ca1d1851c2506fa80c443f00a3d3bf7365004c6b6304f62b0e5cb175210270e75970bb20029b3879ec76c4acd320a8d0589e003636264d01a7d566504bfbac6782012088a9142fb610d856c19fd57f2d0cffe8dff689074b3d8a882103f368228456c940ac113e53dad5c104cf209f2f102a409207269383b6ab9b03deac68ffffffff01d0dc9800000000001976a9146d9d2b554d768232320587df75c4338ecc8bf37d88ac40280e5c").unwrap(); let secret = hex::decode("9da937e5609680cb30bff4a7661364ca1d1851c2506fa80c443f00a3d3bf7365").unwrap(); + let pubkey = coin.my_public_key().unwrap(); let maker_spends_payment_args = SpendPaymentArgs { other_payment_tx: &tx_hex, time_lock: 777, - other_pubkey: coin.my_public_key().unwrap(), + other_pubkey: &pubkey, secret: &secret, secret_hash: &*dhash160(&secret), swap_contract_address: &coin.swap_contract_address(), @@ -551,7 +557,6 @@ fn test_search_for_swap_tx_spend_electrum_was_spent() { search_from_block: 0, swap_contract_address: &None, swap_unique_data: &[], - watcher_reward: false, }; let found = block_on(coin.search_for_swap_tx_spend_my(search_input)) .unwrap() @@ -586,7 +591,6 @@ fn test_search_for_swap_tx_spend_electrum_was_refunded() { search_from_block: 0, swap_contract_address: &None, swap_unique_data: &[], - watcher_reward: false, }; let found = block_on(coin.search_for_swap_tx_spend_my(search_input)) .unwrap() @@ -1680,7 +1684,9 @@ fn test_network_info_negative_time_offset() { let _info: NetworkInfo = json::from_str(info_str).unwrap(); } +// TODO: Re-enable once Electrum servers are dockerized: https://github.com/KomodoPlatform/komodo-defi-framework/issues/2708 #[test] +#[ignore] fn test_unavailable_electrum_proto_version() { ElectrumClientImpl::try_new_arc.mock_safe( |client_settings, block_headers_storage, streaming_manager, abortable_system, event_handlers, chain_variant| { @@ -1761,7 +1767,9 @@ fn test_spam_rick() { } } +// TODO: Re-enable once Electrum servers are dockerized: https://github.com/KomodoPlatform/komodo-defi-framework/issues/2708 #[test] +#[ignore] fn test_one_unavailable_electrum_proto_version() { // First mock with an unrealistically high version requirement that no server would support ElectrumClientImpl::try_new_arc.mock_safe( @@ -1850,7 +1858,9 @@ fn test_qtum_generate_pod() { assert_eq!(expected_res, res.to_string()); } +// TODO: Re-enable once Electrum servers are dockerized: https://github.com/KomodoPlatform/komodo-defi-framework/issues/2708 #[test] +#[ignore] fn test_qtum_add_delegation() { let keypair = key_pair_from_seed("asthma turtle lizard tone genuine tube hunt valley soap cloth urge alpha amazing frost faculty cycle mammal leaf normal bright topple avoid pulse buffalo").unwrap(); let conf = json!({"coin":"tQTUM","rpcport":13889,"pubtype":120,"p2shtype":110, "mature_confirmations":1}); @@ -1926,7 +1936,9 @@ fn test_qtum_add_delegation_on_already_delegating() { assert!(res.is_err()); } +// TODO: Re-enable once Electrum servers are dockerized: https://github.com/KomodoPlatform/komodo-defi-framework/issues/2708 #[test] +#[ignore] fn test_qtum_get_delegation_infos() { let keypair = key_pair_from_seed("federal stay trigger hour exist success game vapor become comfort action phone bright ill target wild nasty crumble dune close rare fabric hen iron").unwrap(); @@ -2826,16 +2838,22 @@ fn test_get_sender_trade_fee_dynamic_tx_fee() { assert_eq!(fee1, fee3); } -// validate an old tx with no output with the burn account -// TODO: remove when we disable such old style txns +// Validate an old tx sent to the legacy DEX fee address (no burn output). +// TODO: Update test fixtures with transactions to new DEX fee address once swaps exist #[test] fn test_validate_old_fee_tx() { let rpc_client = electrum_client_for_test(MARTY_ELECTRUM_ADDRS, ChainVariant::MORTY); let coin = utxo_coin_for_test(UtxoRpcClientEnum::Electrum(rpc_client), None, false); + // This tx was sent to the OLD dex fee address, so we mock dex_pubkey to return the legacy address let tx_bytes = hex::decode("0400008085202f8901033aedb3c3c02fc76c15b393c7b1f638cfa6b4a1d502e00d57ad5b5305f12221000000006a473044022074879aabf38ef943eba7e4ce54c444d2d6aa93ac3e60ea1d7d288d7f17231c5002205e1671a62d8c031ac15e0e8456357e54865b7acbf49c7ebcba78058fd886b4bd012103242d9cb2168968d785f6914c494c303ff1c27ba0ad882dbc3c15cfa773ea953cffffffff0210270000000000001976a914ca1e04745e8ca0c60d8c5881531d51bec470743f88ac4802d913000000001976a914902053231ef0541a7628c11acac40d30f2a127bd88ac008e3765000000000000000000000000000000").unwrap(); let taker_fee_tx = coin.tx_enum_from_bytes(&tx_bytes).unwrap(); let amount: MmNumber = "0.0001".parse::().unwrap().into(); let dex_fee = DexFee::Standard(amount); + + // Mock to use legacy fee address for this historical tx fixture + ::dex_pubkey + .mock_safe(|_| MockResult::Return(DEX_FEE_ADDR_RAW_PUBKEY_LEGACY.as_slice())); + let validate_fee_args = ValidateFeeArgs { fee_tx: &taker_fee_tx, expected_sender: &hex::decode("03242d9cb2168968d785f6914c494c303ff1c27ba0ad882dbc3c15cfa773ea953c").unwrap(), @@ -2846,6 +2864,8 @@ fn test_validate_old_fee_tx() { let result = block_on(coin.validate_fee(validate_fee_args)); log!("result: {:?}", result); assert!(result.is_ok()); + + ::dex_pubkey.clear_mock(); } #[test] @@ -2894,8 +2914,9 @@ fn test_validate_fee_min_block() { } } -#[test] // https://github.com/KomodoPlatform/atomicDEX-API/issues/857 +// TODO: Update test fixtures with transactions to new DEX fee address once swaps exist +#[test] fn test_validate_fee_bch_70_bytes_signature() { let rpc_client = electrum_client_for_test( &[ @@ -2907,10 +2928,16 @@ fn test_validate_fee_bch_70_bytes_signature() { ); let coin = utxo_coin_for_test(UtxoRpcClientEnum::Electrum(rpc_client), None, false); // https://blockchair.com/bitcoin-cash/transaction/ccee05a6b5bbc6f50d2a65a5a3a04690d3e2d81082ad57d3ab471189f53dd70d + // This tx was sent to the OLD dex fee address, so we mock dex_pubkey to return the legacy address let tx_bytes = hex::decode("0100000002cae89775f264e50f14238be86a7184b7f77bfe26f54067b794c546ec5eb9c91a020000006b483045022100d6ed080f722a0637a37552382f462230cc438984bc564bdb4b7094f06cfa38fa022062304a52602df1fbb3bebac4f56e1632ad456f62d9031f4983f07e546c8ec4d8412102ae7dc4ef1b49aadeff79cfad56664105f4d114e1716bc4f930cb27dbd309e521ffffffff11f386a6fe8f0431cb84f549b59be00f05e78f4a8a926c5e023a0d5f9112e8200000000069463043021f17eb93ed20a6f2cd357eabb41a4ec6329000ddc6d5b42ecbe642c5d41b206a022026bc4920c4ce3af751283574baa8e4a3efd4dad0d8fe6ba3ddf5d75628d36fda412102ae7dc4ef1b49aadeff79cfad56664105f4d114e1716bc4f930cb27dbd309e521ffffffff0210270000000000001976a914ca1e04745e8ca0c60d8c5881531d51bec470743f88ac57481c00000000001976a914bac11ce4cd2b1df2769c470d09b54f86df737e3c88ac035b4a60").unwrap(); let taker_fee_tx = coin.tx_enum_from_bytes(&tx_bytes).unwrap(); let amount: BigDecimal = "0.0001".parse().unwrap(); let sender_pub = hex::decode("02ae7dc4ef1b49aadeff79cfad56664105f4d114e1716bc4f930cb27dbd309e521").unwrap(); + + // Mock to use legacy fee address for this historical tx fixture + ::dex_pubkey + .mock_safe(|_| MockResult::Return(DEX_FEE_ADDR_RAW_PUBKEY_LEGACY.as_slice())); + let validate_fee_args = ValidateFeeArgs { fee_tx: &taker_fee_tx, expected_sender: &sender_pub, @@ -2919,6 +2946,8 @@ fn test_validate_fee_bch_70_bytes_signature() { uuid: &[], }; block_on(coin.validate_fee(validate_fee_args)).unwrap(); + + ::dex_pubkey.clear_mock(); } #[test] @@ -5427,9 +5456,36 @@ fn test_block_header_utxo_loop() { let loop_fut = async move { block_header_utxo_loop(weak_client, loop_handle, spv_conf.unwrap()).await }; let test_fut = async move { + // Helper to poll until target height is reached and expected steps are consumed + async fn wait_for_height( + client: &ElectrumClient, + expected_steps: &Arc>>, + target_height: u64, + ) { + repeatable!(async { + let height = client + .block_headers_storage() + .get_last_block_height() + .await + .ok() + .flatten() + .unwrap_or(0); + let steps_empty = expected_steps.lock().unwrap().is_empty(); + if height >= target_height && steps_empty { + Ready(()) + } else { + Retry(()) + } + }) + .repeat_every(Duration::from_millis(100)) + .with_timeout_ms(30_000) + .await + .expect("Timed out waiting for block headers to sync"); + } + *expected_steps.lock().unwrap() = vec![(2, 5), (6, 9), (10, 13), (14, 14)]; CURRENT_BLOCK_COUNT.store(14, Ordering::Relaxed); - Timer::sleep(3.).await; + wait_for_height(&client, &expected_steps, 14).await; let get_headers_count = client .block_headers_storage() .get_last_block_height() @@ -5441,7 +5497,7 @@ fn test_block_header_utxo_loop() { *expected_steps.lock().unwrap() = vec![(15, 18)]; CURRENT_BLOCK_COUNT.store(18, Ordering::Relaxed); - Timer::sleep(2.).await; + wait_for_height(&client, &expected_steps, 18).await; let get_headers_count = client .block_headers_storage() .get_last_block_height() @@ -5453,7 +5509,7 @@ fn test_block_header_utxo_loop() { *expected_steps.lock().unwrap() = vec![(19, 19)]; CURRENT_BLOCK_COUNT.store(19, Ordering::Relaxed); - Timer::sleep(2.).await; + wait_for_height(&client, &expected_steps, 19).await; let get_headers_count = client .block_headers_storage() .get_last_block_height() @@ -5475,7 +5531,6 @@ fn test_block_header_utxo_loop() { assert_eq!(header, None); } - Timer::sleep(2.).await; }; if let Either::Left(_) = block_on(futures::future::select(loop_fut.boxed(), test_fut.boxed())) { diff --git a/mm2src/coins/utxo/wallet_connect.rs b/mm2src/coins/utxo/wallet_connect.rs index de600694ec..217d494962 100644 --- a/mm2src/coins/utxo/wallet_connect.rs +++ b/mm2src/coins/utxo/wallet_connect.rs @@ -1,7 +1,14 @@ //! This module provides functionality to interact with WalletConnect for UTXO-based coins. -use std::convert::TryFrom; +use std::{collections::HashMap, convert::TryFrom}; +use crate::utxo::utxo_common::DEFAULT_SWAP_VIN; +use crate::utxo::{utxo_common, UtxoCoinFields}; +use crate::UtxoTx; +use base64::engine::general_purpose::STANDARD as BASE64_ENGINE; +use base64::Engine; +use bitcoin::{consensus::Decodable, consensus::Encodable, psbt::Psbt, EcdsaSighashType}; use bitcrypto::sign_message_hash; +use chain::bytes::Bytes; use chain::hash::H256; use crypto::StandardHDPath; use kdf_walletconnect::{ @@ -9,11 +16,11 @@ use kdf_walletconnect::{ error::WalletConnectError, WalletConnectCtx, WcTopic, }; -use keys::{CompactSignature, Public}; -use mm2_err_handle::prelude::{MmError, MmResult}; - -use base64::engine::general_purpose::STANDARD as BASE64_ENGINE; -use base64::Engine; +use keys::{Address, CompactSignature, Public}; +use mm2_core::mm_ctx::MmArc; +use mm2_err_handle::prelude::{MapMmError, MapToMmResult, MmError, MmResult}; +use script::{Builder, TransactionInputSigner}; +use serialization::{deserialize, Error as SerError}; /// Represents a UTXO address returned by GetAccountAddresses request in WalletConnect. #[derive(Deserialize)] @@ -85,7 +92,7 @@ struct SignMessageResponse { } /// Get the public key associated with some address via WalletConnect signature. -pub async fn get_pubkey_via_wallatconnect_signature( +pub async fn get_pubkey_via_walletconnect_signature( wc: &WalletConnectCtx, session_topic: &WcTopic, chain_id: &WcChainId, @@ -141,3 +148,331 @@ pub async fn get_pubkey_via_wallatconnect_signature( Ok(pubkey.to_string()) } + +/// The response from WalletConnect for `signPsbt` request. +#[derive(Deserialize)] +struct SignedPsbt { + #[serde(deserialize_with = "common::seri::deserialize_base64")] + psbt: Vec, + #[expect(dead_code)] + txid: Option, +} + +/// The parameters used to instruct WalletConnect how to sign a specific input in a PSBT. +/// +/// An **array** of this struct is sent to WalletConnect in `SignPsbt` request. +#[derive(Serialize)] +struct InputSigningParams { + /// The index of the input to sign. + index: u32, + /// The address to sign the input with. + address: String, + /// The sighash types to use for signing. + sighash_types: Vec, +} + +/// A utility function to sign a PSBT with WalletConnect. +async fn sign_psbt( + wc: &WalletConnectCtx, + session_topic: &WcTopic, + chain_id: &WcChainId, + mut psbt: Psbt, + sign_inputs: Vec, + broadcast: bool, +) -> MmResult { + // Serialize the PSBT and encode it in base64 format. + let mut serialized_psbt = Vec::new(); + psbt.consensus_encode(&mut serialized_psbt).map_to_mm(|e| { + WalletConnectError::InternalError(format!("Failed to serialize our PSBT for WalletConnect: {e}")) + })?; + let serialized_psbt = BASE64_ENGINE.encode(serialized_psbt); + + wc.validate_update_active_chain_id(session_topic, chain_id).await?; + let (account_str, _) = wc.get_account_and_properties_for_chain_id(session_topic, chain_id)?; + let params = json!({ + "account": account_str, + "psbt": serialized_psbt, + "signInputs": sign_inputs, + "broadcast": broadcast, + }); + let signed_psbt: SignedPsbt = wc + .send_session_request_and_wait(session_topic, chain_id, WcRequestMethods::UtxoSignPsbt, params) + .await?; + + let signed_psbt = Psbt::consensus_decode(&mut &signed_psbt.psbt[..]).map_to_mm(|e| { + WalletConnectError::InternalError(format!("Failed to parse signed PSBT from WalletConnect: {e}")) + })?; + + // The signed PSBT has strictly more information than our own PSBT, thus it's enough to proceed with it. + // But we still combine it into our own PSBT to run compatibility validation and make sure WalletConnect didn't send us some nonsense. + psbt.combine(signed_psbt).map_to_mm(|e| { + WalletConnectError::InternalError(format!("Failed to merge the signed PSBT into the unsigned one: {e}")) + })?; + + Ok(psbt) +} + +/// Signs a P2SH transaction that has a single input using WalletConnect. +/// +/// This is to be used for payment spend transactions and refund transactions, where the payment output is being spent. +/// `prev_tx` is the previous transaction that contains the P2SH output being spent. +/// `redeem_script` is the redeem script that is used to spend the P2SH output. +/// `unlocking_script` is the unlocking script that picks the appropriate spending path (normal spend (with secret hash) vs refund) +#[expect(clippy::too_many_arguments)] +pub async fn sign_p2sh_with_walletconnect( + wc: &WalletConnectCtx, + session_topic: &WcTopic, + chain_id: &WcChainId, + signing_address: &Address, + tx_input_signer: &TransactionInputSigner, + prev_tx: UtxoTx, + redeem_script: Bytes, + unlocking_script: Bytes, +) -> MmResult { + let signing_address = signing_address.display_address().map_to_mm(|e| { + WalletConnectError::InternalError(format!("Failed to convert the signing address to a string: {e}")) + })?; + + let mut tx_to_sign: UtxoTx = tx_input_signer.clone().into(); + // Make sure we have exactly one input. We can later safely index inputs (by `[DEFAULT_SWAP_VIN]`) in the transaction and PSBT. + if tx_to_sign.inputs.len() != 1 { + return MmError::err(WalletConnectError::InternalError( + "Expected exactly one input in the PSBT for P2SH signing".to_string(), + )); + } + + let mut psbt = Psbt::from_unsigned_tx(tx_to_sign.clone().into()).map_to_mm(|e| { + WalletConnectError::InternalError(format!("Failed to create PSBT from unsigned transaction: {e}")) + })?; + // Since we are spending a P2SH input, we know for sure it's non-segwit. + psbt.inputs[DEFAULT_SWAP_VIN].non_witness_utxo = Some(prev_tx.into()); + // We need to provide the redeem script as it's used in the signing process. + psbt.inputs[DEFAULT_SWAP_VIN].redeem_script = Some(redeem_script.take().into()); + // TODO: Check whether we should put `fork_id` here or not. When we support a `fork_id`-based chain in WalletConnect. + psbt.inputs[DEFAULT_SWAP_VIN].sighash_type = Some(EcdsaSighashType::All.into()); + + // Ask WalletConnect to sign the PSBT for us. + let inputs = vec![InputSigningParams { + index: DEFAULT_SWAP_VIN as u32, + address: signing_address.clone(), + sighash_types: vec![EcdsaSighashType::All as u8], + }]; + let signed_psbt = sign_psbt(wc, session_topic, chain_id, psbt, inputs, false).await?; + + // WalletConnect can't finalize the scriptSig for us since it doesn't have the unlocking script. + // Thus, the signature for this input must be in the `partial_sigs` field. + let walletconnect_sig = signed_psbt.inputs[DEFAULT_SWAP_VIN] + .partial_sigs + .values() + .next() + .ok_or_else(|| WalletConnectError::InternalError("No signature found in the signed PSBT".to_string()))?; + let redeem_script = signed_psbt.inputs[DEFAULT_SWAP_VIN] + .redeem_script + .as_ref() + .ok_or_else(|| WalletConnectError::InternalError("No redeem script found in the signed PSBT".to_string()))?; + + // The signature and the redeem script are inserted as data. + let p2sh_signature = Builder::default().push_data(&walletconnect_sig.to_vec()).into_bytes(); + let redeem_script = Builder::default().push_data(redeem_script.as_bytes()).into_bytes(); + + let mut final_script_sig = Bytes::new(); + final_script_sig.extend_from_slice(&p2sh_signature); + final_script_sig.extend_from_slice(&unlocking_script); + final_script_sig.extend_from_slice(&redeem_script); + + // Sign the transaction input with the final scriptSig. + tx_to_sign.inputs[DEFAULT_SWAP_VIN].script_sig = final_script_sig; + tx_to_sign.inputs[DEFAULT_SWAP_VIN].script_witness = vec![]; + + Ok(tx_to_sign) +} + +/// Signs a P2SH transaction that has a single input using WalletConnect. +/// +/// This is just another wrapper around `sign_p2sh_with_walletconnect` to avoid some boilerplate given +/// that there is an accessible `coin`. +pub async fn sign_p2sh( + coin: &impl AsRef, + session_topic: &WcTopic, + tx_input_signer: &TransactionInputSigner, + prev_tx: UtxoTx, + redeem_script: Bytes, + unlocking_script: Bytes, +) -> MmResult { + let ctx = MmArc::from_weak(&coin.as_ref().ctx) + .ok_or_else(|| WalletConnectError::InternalError("Couldn't get access to MmArc".to_string()))?; + let wc_ctx = WalletConnectCtx::from_ctx(&ctx)?; + // Get the address that's supposed to sign the P2SH transaction (its signature is required as per the redeem sript). + let address = coin + .as_ref() + .derivation_method + .single_addr() + .await + .ok_or_else(|| WalletConnectError::InternalError("Couldn't get address for P2SH signing".to_string()))?; + let chain_id = coin + .as_ref() + .conf + .chain_id + .as_ref() + .ok_or_else(|| WalletConnectError::InternalError("Chain ID is not set".to_string()))?; + + sign_p2sh_with_walletconnect( + &wc_ctx, + session_topic, + chain_id, + &address, + tx_input_signer, + prev_tx, + redeem_script, + unlocking_script, + ) + .await +} + +/// Signs a P2PKH/P2WPKH spending transaction using WalletConnect. +/// +/// Contrary to what the function name might suggest, this function can sign both P2PKH and **P2WPKH** inputs. +/// `prev_txs` is a map of previous transactions that contain the P2PKH inputs being spent. P2WPKH inputs don't need their previous transactions. +pub async fn sign_p2pkh_with_walletconnect( + wc: &WalletConnectCtx, + session_topic: &WcTopic, + chain_id: &WcChainId, + signing_address: &Address, + tx_input_signer: &TransactionInputSigner, + prev_txs: HashMap, +) -> MmResult { + let signing_address = signing_address.display_address().map_to_mm(|e| { + WalletConnectError::InternalError(format!("Failed to convert the signing address to a string: {e}")) + })?; + + let mut tx_to_sign: UtxoTx = tx_input_signer.clone().into(); + let mut psbt = Psbt::from_unsigned_tx(tx_to_sign.clone().into()).map_to_mm(|e| { + WalletConnectError::InternalError(format!("Failed to create PSBT from unsigned transaction: {e}")) + })?; + + for (psbt_input, input) in psbt.inputs.iter_mut().zip(tx_input_signer.inputs.iter()) { + if input.prev_script.is_pay_to_witness_key_hash() { + // Set the witness output for P2WPKH inputs. + psbt_input.witness_utxo = Some(bitcoin::TxOut { + value: input.amount, + script_pubkey: input.prev_script.to_vec().into(), + }); + } else if input.prev_script.is_pay_to_public_key_hash() { + // Set the previous Transaction for P2PKH inputs. + let prev_tx = prev_txs.get(&input.previous_output.hash).ok_or_else(|| { + WalletConnectError::InternalError(format!( + "Previous transaction not found for P2PKH input: {:?}", + input.previous_output + )) + })?; + psbt_input.non_witness_utxo = Some(prev_tx.clone().into()); + } else { + return MmError::err(WalletConnectError::InternalError(format!( + "Expected a P2WPKH or P2PKH input for WalletConnect signing, got: {}", + input.prev_script + ))); + } + // TODO: Check whether we should put `fork_id` here or not. When we support a `fork_id`-based chain in WalletConnect. + psbt_input.sighash_type = Some(EcdsaSighashType::All.into()); + } + + // Ask WalletConnect to sign the PSBT for us. + let inputs = psbt + .inputs + .iter() + .enumerate() + .map(|(idx, _)| InputSigningParams { + index: idx as u32, + address: signing_address.clone(), + sighash_types: vec![EcdsaSighashType::All as u8], + }) + .collect(); + let signed_psbt = sign_psbt(wc, session_topic, chain_id, psbt, inputs, false).await?; + + for ((psbt_input, input_to_sign), unsigned_input) in signed_psbt + .inputs + .into_iter() + .zip(tx_to_sign.inputs.iter_mut()) + .zip(tx_input_signer.inputs.iter()) + { + input_to_sign.script_sig = Default::default(); + input_to_sign.script_witness = Default::default(); + // If WalletConnect already finalized the script, use it at face value. + // P2(W)PKH inputs are simple enough that some wallets will finalize the script for us. + if let Some(final_script_witness) = psbt_input.final_script_witness { + input_to_sign.script_witness = final_script_witness.to_vec().into_iter().map(Bytes::from).collect(); + } else if let Some(final_script_sig) = psbt_input.final_script_sig { + input_to_sign.script_sig = Bytes::from(final_script_sig.to_bytes()); + } else { + // If WalletConnect didn't finalize the script, we need to figure out whether it's a P2PKH or P2WPKH input and finalize it accordingly. + let (pubkey, walletconnect_sig) = psbt_input.partial_sigs.iter().next().ok_or_else(|| { + WalletConnectError::InternalError("No signature found in the signed PSBT".to_string()) + })?; + if unsigned_input.prev_script.is_pay_to_witness_key_hash() { + input_to_sign.script_witness = + vec![Bytes::from(walletconnect_sig.to_vec()), Bytes::from(pubkey.to_bytes())]; + } else { + input_to_sign.script_sig = Builder::default() + .push_data(&walletconnect_sig.to_vec()) + .push_data(&pubkey.to_bytes()) + .into_bytes(); + } + } + } + + Ok(tx_to_sign) +} + +/// Signs a P2PKH/P2WPKH spending transaction using WalletConnect. +/// +/// This is just another wrapper around `sign_p2pkh_with_walletconnect` to avoid some boilerplate given +/// that there is an accessible `coin`. +pub async fn sign_p2pkh( + coin: &impl AsRef, + session_topic: &WcTopic, + tx_input_signer: &TransactionInputSigner, +) -> MmResult { + let ctx = MmArc::from_weak(&coin.as_ref().ctx) + .ok_or_else(|| WalletConnectError::InternalError("Couldn't get access to MmArc".to_string()))?; + let wc_ctx = WalletConnectCtx::from_ctx(&ctx)?; + let address = + coin.as_ref().derivation_method.single_addr().await.ok_or_else(|| { + WalletConnectError::InternalError("Couldn't get address for P2(W)PKH signing".to_string()) + })?; + let chain_id = coin + .as_ref() + .conf + .chain_id + .as_ref() + .ok_or_else(|| WalletConnectError::InternalError("Chain ID is not set".to_string()))?; + + // Collect the outpoints of each P2PKH input (non-witness ones). + let prev_p2pkh_tx_hashes = tx_input_signer + .inputs + .iter() + .filter(|input| input.prev_script.is_pay_to_public_key_hash()) + .map(|input| input.previous_output.hash.reversed().into()) + .collect(); + // Get the previous transactions that created these P2PKH inputs. + let prev_p2pkh_txs_rpc_format = + utxo_common::get_verbose_transactions_from_cache_or_rpc(coin.as_ref(), prev_p2pkh_tx_hashes) + .await + .mm_err(|e| WalletConnectError::InternalError(format!("Failed to get previous P2PKH transactions: {e}")))?; + let prev_p2pkh_txs = prev_p2pkh_txs_rpc_format + .into_iter() + .map(|(hash, tx)| Ok((hash.reversed().into(), deserialize(tx.into_inner().hex.as_slice())?))) + .collect::>() + .map_err(|e| { + WalletConnectError::InternalError(format!("Failed to deserialize previous P2PKH transactions: {e}")) + })?; + + sign_p2pkh_with_walletconnect( + &wc_ctx, + session_topic, + chain_id, + &address, + tx_input_signer, + prev_p2pkh_txs, + ) + .await +} diff --git a/mm2src/coins/utxo_signer/Cargo.toml b/mm2src/coins/utxo_signer/Cargo.toml index c37b1c675b..f6d244f41c 100644 --- a/mm2src/coins/utxo_signer/Cargo.toml +++ b/mm2src/coins/utxo_signer/Cargo.toml @@ -8,7 +8,7 @@ doctest = false [dependencies] async-trait.workspace = true -chain = { path = "../../mm2_bitcoin/chain" } +chain = { path = "../../mm2_bitcoin/chain", default-features = false } common = { path = "../../common" } mm2_err_handle = { path = "../../mm2_err_handle" } crypto = { path = "../../crypto" } diff --git a/mm2src/coins/utxo_signer/src/lib.rs b/mm2src/coins/utxo_signer/src/lib.rs index 660339c070..97d5c9d814 100644 --- a/mm2src/coins/utxo_signer/src/lib.rs +++ b/mm2src/coins/utxo_signer/src/lib.rs @@ -9,6 +9,7 @@ use rpc::v1::types::{Transaction as RpcTransaction, H256 as H256Json}; use script::Script; mod sign_common; +pub use sign_common::complete_tx; pub mod sign_params; pub mod with_key_pair; pub mod with_trezor; diff --git a/mm2src/coins/utxo_signer/src/sign_common.rs b/mm2src/coins/utxo_signer/src/sign_common.rs index aa2ab355ef..14f41463d6 100644 --- a/mm2src/coins/utxo_signer/src/sign_common.rs +++ b/mm2src/coins/utxo_signer/src/sign_common.rs @@ -5,7 +5,7 @@ use keys::Public as PublicKey; use primitives::hash::{H256, H512}; use script::{Builder, Script, TransactionInputSigner, UnsignedTransactionInput}; -pub(crate) fn complete_tx(unsigned: TransactionInputSigner, signed_inputs: Vec) -> UtxoTx { +pub fn complete_tx(unsigned: TransactionInputSigner, signed_inputs: Vec) -> UtxoTx { UtxoTx { inputs: signed_inputs, n_time: unsigned.n_time, diff --git a/mm2src/coins/watcher_common.rs b/mm2src/coins/watcher_common.rs index 41eb848158..e903312b48 100644 --- a/mm2src/coins/watcher_common.rs +++ b/mm2src/coins/watcher_common.rs @@ -1,9 +1,95 @@ use crate::ValidatePaymentError; use mm2_err_handle::prelude::MmError; -pub const REWARD_GAS_AMOUNT: u64 = 70000; +/// Gas amount used to calculate watcher reward. +/// +/// This value (150K) is set to cover actual watcher gas costs: +/// +/// 1. **Reward functions use more gas than non-reward functions:** +/// - `receiverSpendReward` / `senderRefundReward` have additional hashing (more parameters) +/// - Multiple external transfers (2-4 vs 1 in non-reward functions) +/// - ERC20 is ~2Γ— more expensive (double token transfers + ETH transfers) +/// +/// # Watcher Economics +/// +/// Taker always benefits from watcher actions: +/// - `receiverSpendReward`: Watcher spends maker payment β†’ taker gets coins +/// - `senderRefundReward`: Watcher refunds taker payment β†’ taker gets refund +/// +/// Therefore taker should pay the watcher reward (which the current design does, +/// except for ETH/ETH maker payments which use a shared contract pool). +/// +/// # Future Improvements +/// +/// - Use operation-specific gas constants (measure actual `gasUsed` for each reward function) +/// - Dynamic reward adjustment based on network conditions +pub const REWARD_GAS_AMOUNT: u64 = 150000; + +/// Overpay factor for watcher reward calculation (1.5 = 50% overpay). +/// +/// When calculating watcher reward at payment time, multiply the gas cost by this factor +/// to account for: +/// +/// 1. **Gas price volatility:** Reward is set at payment time but validator checks it later. +/// Gas price can increase significantly between these times. +/// +/// 2. **Profit margin:** Provides buffer for watcher profit (~10%+). +/// +/// The 50% overpay ensures the reward remains valid even if gas price increases by 30-40% +/// between payment creation and validation. +pub const REWARD_OVERPAY_FACTOR: f64 = 1.5; + +/// Margin for reward validation (10%). +/// +/// When validating rewards in non-exact mode, actual reward must be at least +/// `expected_reward * (1 - REWARD_MARGIN)` to pass validation. const REWARD_MARGIN: f64 = 0.1; +/// Validates that the actual watcher reward in a payment transaction is acceptable. +/// +/// # Call sites +/// - `validate_payment` (eth.rs) - counterparty validates payment before proceeding with swap +/// - `watcher_validate_taker_payment` (eth.rs) - watcher validates taker payment before helping +/// +/// # Arguments +/// - `expected_reward`: Reward calculated by validator at validation time (based on current gas price) +/// - `actual_reward`: Reward encoded in the payment transaction (set by payer at payment time) +/// - `is_exact`: If true, requires exact match; if false, only enforces lower bound +/// +/// # Validation logic +/// +/// **Exact mode (`is_exact == true`):** +/// Requires `actual_reward == expected_reward`. Used when reward amount was pre-negotiated. +/// +/// **Non-exact mode (`is_exact == false`):** +/// Only enforces lower bound: `actual_reward >= expected_reward * (1 - REWARD_MARGIN)`. +/// +/// Upper bound is NOT enforced because: +/// 1. The payer of the reward chooses the amount when building the tx. +/// - Maker pays for maker payment reward, taker pays for taker payment reward. +/// - Their node computes `WatcherReward.amount` and encodes it in the contract call. +/// 2. No one can increase `_rewardAmount` after the fact - it's locked into `paymentHash`. +/// The contract's `receiverSpendReward`/`senderRefundReward` require exact hash match. +/// 3. Gas price volatility causes the reward (set at payment time) to exceed the +/// expected reward (calculated by validator at validation time). +/// 4. A higher reward benefits watchers and doesn't reduce what the counterparty receives. +/// +/// Lower bound provides a sanity check that the reward is in a reasonable range, +/// though note that due to gas price volatility, even this check can fail if gas prices +/// rise significantly between payment time and validation time. +/// +/// # Watcher Execution Flexibility +/// +/// - Watcher can execute with less gas than budgeted (they profit more) +/// - Watcher can wait if gas is high and retry later before locktime expires +/// - Multiple watchers can compete - first to call `receiverSpendReward`/`senderRefundReward` +/// gets the reward (reward goes to `msg.sender` in the contract) +/// +/// # Trade Amount Invariants +/// +/// `maker_amount` and `taker_amount` from ordermatching are **net trade amounts**, +/// independent of watcher rewards. The reward is funded separately (e.g., via `msg.value` +/// for ERC20 payments) and does not reduce what the counterparty receives. pub fn validate_watcher_reward( expected_reward: u64, actual_reward: u64, @@ -17,10 +103,9 @@ pub fn validate_watcher_reward( } } else { let min_acceptable_reward = get_reward_lower_boundary(expected_reward); - let max_acceptable_reward = get_reward_upper_boundary(expected_reward); - if actual_reward < min_acceptable_reward || actual_reward > max_acceptable_reward { + if actual_reward < min_acceptable_reward { return MmError::err(ValidatePaymentError::WrongPaymentTx(format!( - "Provided watcher reward {actual_reward} is not within the expected interval {min_acceptable_reward} - {max_acceptable_reward}" + "Provided watcher reward {actual_reward} is less than minimum acceptable {min_acceptable_reward}" ))); } } @@ -30,7 +115,3 @@ pub fn validate_watcher_reward( fn get_reward_lower_boundary(reward: u64) -> u64 { (reward as f64 * (1. - REWARD_MARGIN)) as u64 } - -fn get_reward_upper_boundary(reward: u64) -> u64 { - (reward as f64 * (1. + REWARD_MARGIN)) as u64 -} diff --git a/mm2src/coins/z_coin.rs b/mm2src/coins/z_coin.rs index c72fc7fa4b..f2dc831869 100644 --- a/mm2src/coins/z_coin.rs +++ b/mm2src/coins/z_coin.rs @@ -135,8 +135,10 @@ macro_rules! try_ztx_s { } const DEX_FEE_OVK: OutgoingViewingKey = OutgoingViewingKey([7; 32]); -const DEX_FEE_Z_ADDR: &str = "zs1rp6426e9r6jkq2nsanl66tkd34enewrmr0uvj0zelhkcwmsy0uvxz2fhm9eu9rl3ukxvgzy2v9f"; -const DEX_BURN_Z_ADDR: &str = "zs1ntx28kyurgvsc7rxgkdhasz8p6wzv63nqpcayvnh7c4r6cs4wfkz8ztkwazjzdsxkgaq6erscyl"; +const DEX_FEE_Z_ADDR: &str = "zs18egqw99pw5846jfrqntu4neup4hjjchx874j6krmeq88yh4adws9djmuplg5hfx9f0wdsscgr5j"; +/// Burn disabled - using same address as fee address +const DEX_BURN_Z_ADDR: &str = "zs18egqw99pw5846jfrqntu4neup4hjjchx874j6krmeq88yh4adws9djmuplg5hfx9f0wdsscgr5j"; + cfg_native!( #[cfg(test)] const DOWNLOAD_URL: &str = "https://komodoplatform.com/downloads"; @@ -751,6 +753,21 @@ impl ZCoin { } } +/// Methods used for DEX fee validation that can be mocked in tests +/// to return legacy addresses for historical transaction fixtures. +#[cfg_attr(test, mockable)] +impl ZCoin { + /// Returns the DEX fee z-address for fee validation. + fn dex_fee_addr(&self) -> PaymentAddress { + self.z_fields.dex_fee_addr.clone() + } + + /// Returns the DEX burn z-address for fee validation. + fn dex_burn_addr(&self) -> PaymentAddress { + self.z_fields.dex_burn_addr.clone() + } +} + impl AsRef for ZCoin { fn as_ref(&self) -> &UtxoCoinFields { &self.utxo_arc @@ -1013,8 +1030,10 @@ impl UtxoCoinBuilder for ZCoinBuilder<'_> { ) .await .map_mm_err()?, - #[cfg(test)] + #[cfg(all(test, not(target_arch = "wasm32")))] ZcoinRpcMode::UnitTests => z_unit_tests::create_test_sync_connector(&self).await, + #[cfg(all(test, target_arch = "wasm32"))] + ZcoinRpcMode::UnitTests => unreachable!("UnitTests mode is not supported on WASM"), }; let z_fields = Arc::new(ZCoinFields { @@ -1607,12 +1626,14 @@ impl SwapOps for ZCoin { let mut fee_output_valid = false; let mut burn_output_valid = false; + let dex_fee_addr = self.dex_fee_addr(); + let dex_burn_addr = self.dex_burn_addr(); for shielded_out in z_tx.shielded_outputs.iter() { if self .validate_dex_fee_output( shielded_out, &DEX_FEE_OVK, - &self.z_fields.dex_fee_addr, + &dex_fee_addr, block_height, fee_amount_sat, &expected_memo, @@ -1630,7 +1651,7 @@ impl SwapOps for ZCoin { .validate_dex_fee_output( shielded_out, &DEX_FEE_OVK, - &self.z_fields.dex_burn_addr, + &dex_burn_addr, block_height, burn_amount_sat, &expected_memo, @@ -1700,12 +1721,7 @@ impl SwapOps for ZCoin { } #[inline] - async fn extract_secret( - &self, - secret_hash: &[u8], - spend_tx: &[u8], - _watcher_reward: bool, - ) -> Result<[u8; 32], String> { + async fn extract_secret(&self, secret_hash: &[u8], spend_tx: &[u8]) -> Result<[u8; 32], String> { utxo_common::extract_secret(secret_hash, spend_tx) } @@ -1950,7 +1966,7 @@ impl UtxoCommonOps for ZCoin { utxo_common::denominate_satoshis(&self.utxo_arc, satoshi) } - fn my_public_key(&self) -> Result<&Public, MmError> { + fn my_public_key(&self) -> Result> { utxo_common::my_public_key(self.as_ref()) } @@ -1988,7 +2004,7 @@ impl UtxoCommonOps for ZCoin { utxo_common::get_mut_verbose_transaction_from_map_or_rpc(self, tx_hash, utxo_tx_map).await } - async fn p2sh_spending_tx(&self, input: utxo_common::P2SHSpendingTxInput<'_>) -> Result { + async fn p2sh_spending_tx(&self, input: utxo_common::P2SHSpendingTxInput) -> Result { utxo_common::p2sh_spending_tx(self, input).await } diff --git a/mm2src/coins/z_coin/storage/walletdb/wasm/mod.rs b/mm2src/coins/z_coin/storage/walletdb/wasm/mod.rs index 1371c9736b..94d80af349 100644 --- a/mm2src/coins/z_coin/storage/walletdb/wasm/mod.rs +++ b/mm2src/coins/z_coin/storage/walletdb/wasm/mod.rs @@ -75,7 +75,6 @@ mod wasm_test { use mm2_event_stream::StreamingManager; use mm2_test_helpers::for_tests::mm_ctx_with_custom_db; use protobuf::Message; - use std::path::PathBuf; use wasm_bindgen_test::*; use zcash_client_backend::wallet::{AccountId, OvkPolicy}; use zcash_extras::fake_compact_block; @@ -208,12 +207,8 @@ mod wasm_test { async fn test_valid_chain_state() { // init blocks_db let ctx = mm_ctx_with_custom_db(); - let locked_notes_db = LockedNotesStorage::new(ctx.clone(), MY_ADDRESS.to_string()) - .await - .unwrap(); - let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string(), PathBuf::new()) - .await - .unwrap(); + let locked_notes_db = LockedNotesStorage::new(&ctx, MY_ADDRESS.to_string()).await.unwrap(); + let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string()).await.unwrap(); // init walletdb. let walletdb = wallet_db_from_zcoin_builder_for_test(&ctx, TICKER).await; @@ -336,12 +331,8 @@ mod wasm_test { async fn invalid_chain_cache_disconnected() { // init blocks_db let ctx = mm_ctx_with_custom_db(); - let locked_notes_db = LockedNotesStorage::new(ctx.clone(), MY_ADDRESS.to_string()) - .await - .unwrap(); - let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string(), PathBuf::new()) - .await - .unwrap(); + let locked_notes_db = LockedNotesStorage::new(&ctx, MY_ADDRESS.to_string()).await.unwrap(); + let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string()).await.unwrap(); // init walletdb. let walletdb = wallet_db_from_zcoin_builder_for_test(&ctx, TICKER).await; @@ -436,12 +427,8 @@ mod wasm_test { async fn test_invalid_chain_reorg() { // init blocks_db let ctx = mm_ctx_with_custom_db(); - let locked_notes_db = LockedNotesStorage::new(ctx.clone(), MY_ADDRESS.to_string()) - .await - .unwrap(); - let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string(), PathBuf::new()) - .await - .unwrap(); + let locked_notes_db = LockedNotesStorage::new(&ctx, MY_ADDRESS.to_string()).await.unwrap(); + let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string()).await.unwrap(); // init walletdb. let walletdb = wallet_db_from_zcoin_builder_for_test(&ctx, TICKER).await; @@ -536,12 +523,8 @@ mod wasm_test { async fn test_data_db_rewinding() { // init blocks_db let ctx = mm_ctx_with_custom_db(); - let locked_notes_db = LockedNotesStorage::new(ctx.clone(), MY_ADDRESS.to_string()) - .await - .unwrap(); - let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string(), PathBuf::new()) - .await - .unwrap(); + let locked_notes_db = LockedNotesStorage::new(&ctx, MY_ADDRESS.to_string()).await.unwrap(); + let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string()).await.unwrap(); // init walletdb. let walletdb = wallet_db_from_zcoin_builder_for_test(&ctx, TICKER).await; @@ -617,12 +600,8 @@ mod wasm_test { async fn test_scan_cached_blocks_requires_sequential_blocks() { // init blocks_db let ctx = mm_ctx_with_custom_db(); - let locked_notes_db = LockedNotesStorage::new(ctx.clone(), MY_ADDRESS.to_string()) - .await - .unwrap(); - let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string(), PathBuf::new()) - .await - .unwrap(); + let locked_notes_db = LockedNotesStorage::new(&ctx, MY_ADDRESS.to_string()).await.unwrap(); + let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string()).await.unwrap(); // init walletdb. let walletdb = wallet_db_from_zcoin_builder_for_test(&ctx, TICKER).await; @@ -706,12 +685,8 @@ mod wasm_test { async fn test_scan_cached_blokcs_finds_received_notes() { // init blocks_db let ctx = mm_ctx_with_custom_db(); - let locked_notes_db = LockedNotesStorage::new(ctx.clone(), MY_ADDRESS.to_string()) - .await - .unwrap(); - let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string(), PathBuf::new()) - .await - .unwrap(); + let locked_notes_db = LockedNotesStorage::new(&ctx, MY_ADDRESS.to_string()).await.unwrap(); + let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string()).await.unwrap(); // init walletdb. let walletdb = wallet_db_from_zcoin_builder_for_test(&ctx, TICKER).await; @@ -774,12 +749,8 @@ mod wasm_test { async fn test_scan_cached_blocks_finds_change_notes() { // init blocks_db let ctx = mm_ctx_with_custom_db(); - let locked_notes_db = LockedNotesStorage::new(ctx.clone(), MY_ADDRESS.to_string()) - .await - .unwrap(); - let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string(), PathBuf::new()) - .await - .unwrap(); + let locked_notes_db = LockedNotesStorage::new(&ctx, MY_ADDRESS.to_string()).await.unwrap(); + let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string()).await.unwrap(); // init walletdb. let walletdb = wallet_db_from_zcoin_builder_for_test(&ctx, TICKER).await; @@ -857,8 +828,8 @@ mod wasm_test { // async fn create_to_address_fails_on_unverified_notes() { // // init blocks_db // let ctx = mm_ctx_with_custom_db(); - // let locked_notes_db = LockedNotesStorage::new(ctx.clone(), MY_ADDRESS.to_string()).await.unwrap(); - // let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string(), PathBuf::new()).await.unwrap(); + // let locked_notes_db = LockedNotesStorage::new(&ctx, MY_ADDRESS.to_string()).await.unwrap(); + // let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string()).await.unwrap(); // // // init walletdb. // let mut walletdb = wallet_db_from_zcoin_builder_for_test(&ctx, TICKER).await; @@ -1127,8 +1098,8 @@ mod wasm_test { // // // init blocks_db // let ctx = mm_ctx_with_custom_db(); - // let locked_notes_db = LockedNotesStorage::new(ctx.clone(), MY_ADDRESS.to_string()).await.unwrap(); - // let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string(), PathBuf::new()).await.unwrap(); + // let locked_notes_db = LockedNotesStorage::new(&ctx, MY_ADDRESS.to_string()).await.unwrap(); + // let blockdb = BlockDbImpl::new(&ctx, TICKER.to_string()).await.unwrap(); // // // init walletdb. // let mut walletdb = wallet_db_from_zcoin_builder_for_test(&ctx, TICKER).await; diff --git a/mm2src/coins/z_coin/z_htlc.rs b/mm2src/coins/z_coin/z_htlc.rs index 27d6cfb485..1fbe01f692 100644 --- a/mm2src/coins/z_coin/z_htlc.rs +++ b/mm2src/coins/z_coin/z_htlc.rs @@ -7,7 +7,7 @@ use super::{GenTxError, ZCoin}; use crate::utxo::rpc_clients::{UtxoRpcClientEnum, UtxoRpcError}; -use crate::utxo::utxo_common::payment_script; +use crate::utxo::utxo_common::{payment_script, DEFAULT_SWAP_VOUT}; use crate::utxo::{sat_from_big_decimal, UtxoAddressFormat}; use crate::z_coin::SendOutputsErr; use crate::z_coin::{ZOutput, DEX_FEE_OVK}; @@ -182,9 +182,9 @@ pub async fn z_p2sh_spend( let secp_secret = SecretKey::from_slice(htlc_keypair.private_ref()).expect("Keypair contains a valid secret key"); - let outpoint = ZCashOutpoint::new(p2sh_tx.txid().0, 0); + let outpoint = ZCashOutpoint::new(p2sh_tx.txid().0, DEFAULT_SWAP_VOUT as u32); let tx_out = TxOut { - value: p2sh_tx.vout[0].value, + value: p2sh_tx.vout[DEFAULT_SWAP_VOUT].value, script_pubkey: ZCashScript(redeem_script.to_vec()), }; tx_builder.add_transparent_input( @@ -198,7 +198,7 @@ pub async fn z_p2sh_spend( None, coin.z_fields.my_z_addr.clone(), // TODO use fee from coin here. Will do on next iteration, 1000 is default value that works fine - p2sh_tx.vout[0].value - Amount::from_i64(1000).expect("1000 will always succeed"), + p2sh_tx.vout[DEFAULT_SWAP_VOUT].value - Amount::from_i64(1000).expect("1000 will always succeed"), None, )?; diff --git a/mm2src/coins/z_coin/z_unit_tests.rs b/mm2src/coins/z_coin/z_unit_tests.rs index 42cef2e6fb..408ea6b5df 100644 --- a/mm2src/coins/z_coin/z_unit_tests.rs +++ b/mm2src/coins/z_coin/z_unit_tests.rs @@ -26,6 +26,12 @@ use zcash_primitives::transaction::components::amount::DEFAULT_FEE; const GITHUB_CLIENT_USER_AGENT: &str = "mm2"; +/// Legacy DEX fee z-address for unit test fixtures. +/// Used to test WithBurn validation with different fee and burn addresses. +const DEX_FEE_Z_ADDR_LEGACY: &str = "zs1rp6426e9r6jkq2nsanl66tkd34enewrmr0uvj0zelhkcwmsy0uvxz2fhm9eu9rl3ukxvgzy2v9f"; +/// Legacy DEX burn z-address for unit test fixtures. +const DEX_BURN_Z_ADDR_LEGACY: &str = "zs1ntx28kyurgvsc7rxgkdhasz8p6wzv63nqpcayvnh7c4r6cs4wfkz8ztkwazjzdsxkgaq6erscyl"; + /// Download zcash params from komodo repo async fn fetch_and_save_params(param: &str, fname: &Path) -> Result<(), String> { let url = Url::parse(&format!("{DOWNLOAD_URL}/")).unwrap().join(param).unwrap(); @@ -285,60 +291,104 @@ fn test_interpret_memo_string() { assert_eq!(actual, expected); } +/// Tests ZCoin DEX fee validation with Standard and WithBurn fees. +/// Uses mocking to set legacy addresses (different fee and burn addresses) +/// so we can properly test the WithBurn validation logic. #[tokio::test] async fn test_validate_zcoin_dex_fee() { let (_ctx, coin) = z_coin_from_spending_key_for_unit_test("secret-extended-key-main1qvqstxphqyqqpqqnh3hstqpdjzkpadeed6u7fz230jmm2mxl0aacrtu9vt7a7rmr2w5az5u79d24t0rudak3newknrz5l0m3dsd8m4dffqh5xwyldc5qwz8pnalrnhlxdzf900x83jazc52y25e9hvyd4kepaze6nlcvk8sd8a4qjh3e9j5d6730t7ctzhhrhp0zljjtwuptadnksxf8a8y5axwdhass5pjaxg0hzhg7z25rx0rll7a6txywl32s6cda0s5kexr03uqdtelwe").await; + // Decode legacy addresses for testing WithBurn (different fee and burn addresses) + let consensus_params = coin.consensus_params(); + let hrp = consensus_params.hrp_sapling_payment_address(); + let legacy_fee_addr = decode_payment_address(hrp, DEX_FEE_Z_ADDR_LEGACY) + .expect("valid z address format") + .expect("valid z address"); + let legacy_burn_addr = decode_payment_address(hrp, DEX_BURN_Z_ADDR_LEGACY) + .expect("valid z address format") + .expect("valid z address"); + + // Mock dex_fee_addr and dex_burn_addr to return legacy addresses + let fee_addr_for_mock = legacy_fee_addr.clone(); + ZCoin::dex_fee_addr.mock_safe(move |_| MockResult::Return(fee_addr_for_mock.clone())); + let burn_addr_for_mock = legacy_burn_addr.clone(); + ZCoin::dex_burn_addr.mock_safe(move |_| MockResult::Return(burn_addr_for_mock.clone())); + + // Test standard fee validation let std_fee = DexFee::Standard("0.001".into()); + assert!( + validate_fee_caller(&coin, (legacy_fee_addr.clone(), 100000), None, &std_fee) + .await + .is_ok() + ); + + // Test WithBurn fee validation - using different fee and burn addresses let with_burn = DexFee::WithBurn { fee_amount: "0.0075".into(), burn_amount: "0.0025".into(), burn_destination: DexFeeBurnDestination::PreBurnAccount, }; - assert!( - validate_fee_caller(&coin, (coin.z_fields.dex_fee_addr.clone(), 100000), None, &std_fee) - .await - .is_ok() - ); assert!(validate_fee_caller( &coin, - (coin.z_fields.dex_fee_addr.clone(), 750000), - Some((coin.z_fields.dex_burn_addr.clone(), 250000)), + (legacy_fee_addr.clone(), 750000), + Some((legacy_burn_addr.clone(), 250000)), &with_burn ) .await .is_ok()); - // try reverted addresses + + // Test reverted addresses - should fail because fee and burn addresses are swapped assert!(validate_fee_caller( &coin, - (coin.z_fields.dex_burn_addr.clone(), 750000), - Some((coin.z_fields.dex_fee_addr.clone(), 250000)), + (legacy_burn_addr.clone(), 750000), + Some((legacy_fee_addr.clone(), 250000)), &with_burn ) .await .is_err()); + + // Test with a completely different address - should fail let other_addr = decode_payment_address( - coin.z_fields.consensus_params.hrp_sapling_payment_address(), + hrp, "zs182ht30wnnnr8jjhj2j9v5dkx3qsknnr5r00jfwk2nczdtqy7w0v836kyy840kv2r8xle5gcl549", ) .expect("valid z address format") .expect("valid z address"); - // try invalid dex address + + // Fee sent to wrong address should fail + assert!(validate_fee_caller(&coin, (other_addr.clone(), 100000), None, &std_fee) + .await + .is_err()); + + // Invalid dex address in WithBurn should fail assert!(validate_fee_caller( &coin, (other_addr.clone(), 750000), - Some((coin.z_fields.dex_burn_addr.clone(), 250000)), + Some((legacy_burn_addr.clone(), 250000)), &with_burn ) .await .is_err()); - // try invalid burn address + + // Invalid burn address should fail assert!(validate_fee_caller( &coin, - (coin.z_fields.dex_fee_addr.clone(), 750000), + (legacy_fee_addr.clone(), 750000), Some((other_addr.clone(), 250000)), &with_burn ) .await .is_err()); + + // Test with larger fee amount + let large_fee = DexFee::Standard("0.02".into()); + assert!( + validate_fee_caller(&coin, (legacy_fee_addr.clone(), 2000000), None, &large_fee) + .await + .is_ok() + ); + + // Clean up mocks + ZCoin::dex_fee_addr.clear_mock(); + ZCoin::dex_burn_addr.clear_mock(); } diff --git a/mm2src/coins_activation/AGENTS.md b/mm2src/coins_activation/AGENTS.md new file mode 100644 index 0000000000..bb90ee0a35 --- /dev/null +++ b/mm2src/coins_activation/AGENTS.md @@ -0,0 +1,244 @@ +# coins_activation β€” Coin Activation Flows + +> **Note:** Always follow the root `/CLAUDE.md` for global conventions (fmt, clippy, error handling, etc.). + +Manages the lifecycle of cryptocurrency activation. Handles standalone coins, platform coins with tokens, and L2 layers. + +## Responsibilities + +- Task-based coin activation via `RpcTaskManager` +- Platform coin + token initialization (ETH+ERC20, TRON, BCH+SLP, Tendermint+IBC, Solana+SPL) +- Standalone coin activation (UTXO, Qtum, ZCash, Sia) +- L2/Lightning activation (native only) +- Hardware wallet interaction during activation +- Transaction history fetching initiation + +## Module Structure + +``` +src/ +β”œβ”€β”€ lib.rs # Exports all activation functions +β”œβ”€β”€ prelude.rs # Common imports +β”œβ”€β”€ context.rs # CoinsActivationContext with task managers +β”œβ”€β”€ platform_coin_with_tokens.rs # Platform + tokens activation trait/impl +β”œβ”€β”€ standalone_coin/ # Standalone coin activation +β”‚ β”œβ”€β”€ init_standalone_coin.rs # Generic standalone activation +β”‚ β”œβ”€β”€ init_standalone_coin_error.rs # Standalone activation errors +β”‚ └── mod.rs # Module exports +β”œβ”€β”€ token.rs # Token-only activation (enable_token) +β”œβ”€β”€ init_token.rs # Task-based token init +β”œβ”€β”€ l2/ # Lightning/L2 activation +β”‚ β”œβ”€β”€ init_l2.rs # L2 activation logic +β”‚ β”œβ”€β”€ init_l2_error.rs # L2 activation errors +β”‚ └── mod.rs # Module exports +β”œβ”€β”€ eth_with_token_activation.rs # ETH + ERC20/NFT +β”œβ”€β”€ erc20_token_activation.rs # ERC20 token activation +β”œβ”€β”€ init_erc20_token_activation.rs # Task-based ERC20 init +β”œβ”€β”€ bch_with_tokens_activation.rs # BCH + SLP +β”œβ”€β”€ slp_token_activation.rs # SLP token activation +β”œβ”€β”€ tendermint_with_assets_activation.rs # Tendermint + IBC +β”œβ”€β”€ tendermint_token_activation.rs # Tendermint token activation +β”œβ”€β”€ solana_with_assets.rs # Solana + SPL (experimental) +β”œβ”€β”€ solana_token_activation.rs # SPL token activation +β”œβ”€β”€ utxo_activation/ # UTXO coin activation +β”‚ β”œβ”€β”€ init_utxo_standard_activation.rs # UTXO standard activation +β”‚ β”œβ”€β”€ init_utxo_standard_activation_error.rs # UTXO activation errors +β”‚ β”œβ”€β”€ init_utxo_standard_statuses.rs # UTXO activation status types +β”‚ β”œβ”€β”€ utxo_standard_activation_result.rs # UTXO activation result types +β”‚ β”œβ”€β”€ init_bch_activation.rs # BCH standalone activation +β”‚ β”œβ”€β”€ init_qtum_activation.rs # Qtum standalone activation +β”‚ β”œβ”€β”€ common_impl.rs # Shared UTXO activation logic +β”‚ └── mod.rs # Module exports +β”œβ”€β”€ z_coin_activation.rs # ZCash +β”œβ”€β”€ sia_coin_activation.rs # Sia +└── lightning_activation.rs # Lightning (native only) +``` + +## Activation Patterns + +### 1. Platform Coin with Tokens + +For coins that host tokens (ETH, TRON, BCH, Tendermint, Solana): + +```rust +// RPC: "task::enable_eth_with_tokens::init" +trait PlatformCoinWithTokensActivationOps { + async fn enable_platform_coin(...) -> Result; + async fn enable_global_nft(...) -> Result, Error>; + fn token_initializers(&self) -> Vec>; + async fn get_activation_result(...) -> Result; +} +``` + +Flow: +1. Check if already activated +2. Load platform config and protocol +3. Create platform coin instance +4. Initialize tokens via `token_initializers()` +5. Enable global NFT if applicable +6. Get activation result (block height, balances) +7. Start tx history fetching if enabled +8. Register with `CoinsContext` + +**TRON-specific behavior:** +- Uses same `eth_with_token_activation.rs` flow as ETH +- Identified by `ChainSpec::Tron { network }` (vs `ChainSpec::Evm { chain_id }`) +- Wallet-only mode: rejects token/NFT activation requests +- Address format: `TronAddress` with Base58Check encoding (`T...` prefix) +- RPC abstraction: `ChainRpcClient::Tron` implements `ChainRpcOps` for balance/block queries + +### 2. Standalone Coins + +For coins without token support (UTXO, Qtum, ZCash, Sia): + +```rust +// RPC: "task::enable_z_coin::init" +trait InitStandaloneCoinActivationOps { + async fn init_standalone_coin(...) -> Result; + async fn get_activation_result(...) -> Result; + fn start_history_background_fetching(...); +} +``` + +### 3. Token-Only Activation + +For adding tokens to already-active platform: + +```rust +// Task-based (preferred): "task::enable_erc20::init" +trait InitTokenActivationOps { + async fn init_token(...) -> Result; + async fn get_activation_result(...) -> Result; +} + +// Request-response: "enable_erc20", "enable_slp" +trait TokenActivationOps { + async fn enable_token(...) -> Result; +} +``` + +### 4. L2 Activation + +For Lightning Network (native only): + +```rust +// RPC: "task::enable_lightning::init" +trait InitL2ActivationOps { + async fn init_l2(...) -> Result; + async fn get_activation_result(...) -> Result; +} +``` + +## Core Types + +### CoinsActivationContext + +Central context holding all task managers: + +```rust +struct CoinsActivationContext { + init_utxo_standard_task_manager: UtxoStandardTaskManagerShared, + init_eth_task_manager: EthTaskManagerShared, + init_z_coin_task_manager: ZcoinTaskManagerShared, + init_tendermint_coin_task_manager: TendermintCoinTaskManagerShared, + // ... more task managers +} +``` + +### RpcTaskManager + +Handles async activation lifecycle: +- `spawn_rpc_task()` β€” Start activation, returns `task_id` +- `task_status()` β€” Poll completion status +- `on_user_action()` β€” Handle HW wallet prompts +- `cancel_task()` β€” Abort activation + +### Activation Status States + +```rust +enum InitPlatformCoinWithTokensInProgressStatus { + ActivatingCoin, + SyncingBlockHeaders { current_scanned_block, last_block }, + TemporaryError(String), + RequestingWalletBalance, + WaitingForTrezorToConnect, + FollowHwDeviceInstructions, + Finishing, +} +``` + +## RPC Endpoints + +Task-based (supports `::init`, `::status`, `::user_action`, `::cancel`): +| Pattern | Example | +|---------|---------| +| Platform+Tokens | `task::enable_eth::init`, `task::enable_tendermint::init` | +| Standalone | `task::enable_utxo::init`, `task::enable_z_coin::init`, `task::enable_sia::init` | +| Token | `task::enable_erc20::init` | +| L2 | `task::enable_lightning::init` | + +Request-response (no task management): +| Pattern | Example | +|---------|---------| +| Platform+Tokens | `enable_eth_with_tokens`, `enable_bch_with_tokens` | +| Standalone | `enable_sia` | +| Token | `enable_erc20`, `enable_slp`, `enable_tendermint_token` | + +## Key Traits + +| Trait | Purpose | Implementors | +|-------|---------|--------------| +| `PlatformCoinWithTokensActivationOps` | Platform + tokens | EthCoin, BchCoin, TendermintCoin, SolanaCoin | +| `InitStandaloneCoinActivationOps` | Standalone coins | UtxoStandardCoin, QtumCoin, BchCoin, ZCoin, SiaCoin | +| `InitTokenActivationOps` | Token (task-based) | EthCoin | +| `TokenActivationOps` | Token (request-response) | EthCoin, SlpToken, TendermintToken, SolanaToken | +| `InitL2ActivationOps` | L2 activation | LightningCoin | +| `TokenInitializer` | Token creation | Erc20Initializer, SlpTokenInitializer, TendermintTokenInitializer | + +## Interactions + +| Crate | Usage | +|-------|-------| +| **coins** | Coin types implement activation traits | +| **mm2_main** | RPC dispatcher routes to activation functions | +| **crypto** | `PrivKeyBuildPolicy` detection for key source | +| **rpc_task** | RpcTaskManager for task lifecycle | +| **mm2_core** | MmArc context, CoinsContext registration | +| **mm2_err_handle** | MmError framework | +| **mm2_event_stream** | Progress event streaming | +| **common** | Utilities, HttpStatusCode, executor | +| **mm2_number** | BigDecimal for balances | +| **kdf_walletconnect** | WalletConnect context for external wallets | + +## Key Invariants + +- Platform coin must be activated before its tokens or L2 +- Duplicate activation prevented (checked at start of each activation) +- Task-based activation required for hardware wallet flows +- Coin registered with `CoinsContext` only after successful activation +- Activation can be cancelled; partially activated coins are cleaned up + +## Error Handling + +Common activation errors: +- `PlatformIsAlreadyActivated` β€” Coin already active +- `CoinIsAlreadyActivated` β€” Standalone coin already active +- `PlatformCoinIsNotActivated` β€” Token/L2 activated before platform +- `PlatformConfigIsNotFound` β€” Missing coin config +- `TokenConfigIsNotFound` β€” Missing token config +- `UnexpectedPlatformProtocol` β€” Protocol mismatch +- `TaskTimedOut` β€” Activation took too long + +All errors implement `HttpStatusCode` for proper RPC responses. + +## Adding New Coin Activation + +1. Implement appropriate trait (`PlatformCoinWithTokensActivationOps` or `InitStandaloneCoinActivationOps`) +2. Add task manager to `CoinsActivationContext` +3. Create activation module (e.g., `my_coin_activation.rs`) +4. Wire up RPC endpoints in dispatcher + +## Tests + +- Integration: Platform activation tests in `mm2_main/tests/` +- TRON tests: `cargo test --test mm2_tests_main --features tron-network-tests tron_` diff --git a/mm2src/coins_activation/Cargo.toml b/mm2src/coins_activation/Cargo.toml index 86f8ab7a13..dec1615a17 100644 --- a/mm2src/coins_activation/Cargo.toml +++ b/mm2src/coins_activation/Cargo.toml @@ -7,12 +7,13 @@ edition = "2018" doctest = false [features] -default = [] +default = ["utxo-walletconnect"] +utxo-walletconnect = ["coins/utxo-walletconnect"] for-tests = [] [dependencies] async-trait.workspace = true -coins = { path = "../coins" } +coins = { path = "../coins", default-features = false } common = { path = "../common" } crypto = { path = "../crypto" } derive_more.workspace = true @@ -28,7 +29,6 @@ mm2_number = { path = "../mm2_number" } parking_lot = { workspace = true } rpc = { path = "../mm2_bitcoin/rpc" } rpc_task = { path = "../rpc_task" } -secp256k1 = { version = "0.24" } ser_error = { path = "../derives/ser_error" } ser_error_derive = { path = "../derives/ser_error_derive" } serde.workspace = true @@ -43,3 +43,4 @@ mm2_metamask = { path = "../mm2_metamask" } lightning.workspace = true lightning-background-processor.workspace = true lightning-invoice.workspace = true +secp256k1v24.workspace = true diff --git a/mm2src/coins_activation/src/bch_with_tokens_activation.rs b/mm2src/coins_activation/src/bch_with_tokens_activation.rs index 95af24e839..0b5e521792 100644 --- a/mm2src/coins_activation/src/bch_with_tokens_activation.rs +++ b/mm2src/coins_activation/src/bch_with_tokens_activation.rs @@ -63,6 +63,7 @@ impl TokenInitializer for SlpTokenInitializer { platform_params.slp_tokens_requests.clone() } + #[allow(clippy::result_large_err)] async fn enable_tokens( &self, activation_params: Vec>, diff --git a/mm2src/coins_activation/src/erc20_token_activation.rs b/mm2src/coins_activation/src/erc20_token_activation.rs index 25a8df5cd5..a397cdeb31 100644 --- a/mm2src/coins_activation/src/erc20_token_activation.rs +++ b/mm2src/coins_activation/src/erc20_token_activation.rs @@ -8,6 +8,7 @@ use coins::hd_wallet::DisplayAddress; use coins::nft::nft_structs::NftInfo; use coins::{ eth::{ + tron::TronAddress, v2_activation::{Erc20Protocol, EthTokenActivationError}, valid_addr_from_str, EthCoin, }, @@ -18,6 +19,7 @@ use mm2_err_handle::prelude::*; use serde::Serialize; use serde_json::Value as Json; use std::collections::HashMap; +use std::str::FromStr; #[derive(Debug, Serialize)] #[serde(untagged)] @@ -86,6 +88,19 @@ impl TryFromCoinProtocol for Erc20Protocol { Ok(Erc20Protocol { platform, token_addr }) }, + CoinProtocol::TRC20 { + platform, + contract_address, + } => { + // Parse TRON address (Base58 T... or hex 41...) and convert to raw 20-byte EVM address + let tron_addr = TronAddress::from_str(&contract_address).map_err(|_| CoinProtocol::TRC20 { + platform: platform.clone(), + contract_address, + })?; + let token_addr = tron_addr.to_evm_address(); + + Ok(Erc20Protocol { platform, token_addr }) + }, proto => MmError::err(proto), } } @@ -109,7 +124,8 @@ impl TryFromCoinProtocol for EthTokenProtocol { Self: Sized, { match proto { - CoinProtocol::ERC20 { .. } => { + // Both ERC20 and TRC20 are handled as Erc20Protocol internally + CoinProtocol::ERC20 { .. } | CoinProtocol::TRC20 { .. } => { let erc20_protocol = Erc20Protocol::try_from_coin_protocol(proto)?; Ok(EthTokenProtocol::Erc20(erc20_protocol)) }, @@ -172,6 +188,8 @@ impl TokenActivationOps for EthCoin { let token_contract_address = token.erc20_token_address().ok_or_else(|| { EthTokenActivationError::InternalError("Token contract address is missing".to_string()) })?; + // Format contract address chain-aware: EVM checksum (0x) or TRON Base58 (T...) + let token_contract_address = token.format_raw_address(token_contract_address); let balance = token .my_balance() @@ -185,7 +203,7 @@ impl TokenActivationOps for EthCoin { balances, platform_coin: token.platform_ticker().to_owned(), required_confirmations: token.required_confirmations(), - token_contract_address: format!("{token_contract_address:#02x}"), + token_contract_address, }); Ok((token, init_result)) diff --git a/mm2src/coins_activation/src/eth_with_token_activation.rs b/mm2src/coins_activation/src/eth_with_token_activation.rs index 0f40c09f71..96c16720f2 100644 --- a/mm2src/coins_activation/src/eth_with_token_activation.rs +++ b/mm2src/coins_activation/src/eth_with_token_activation.rs @@ -310,6 +310,14 @@ impl PlatformCoinWithTokensActivationOps for EthCoin { activation_request: Self::ActivationRequest, protocol: Self::PlatformProtocolInfo, ) -> Result> { + // TRON doesn't support NFTs yet + if matches!(protocol, ChainSpec::Tron { .. }) && activation_request.nft_req.is_some() { + return MmError::err(EthActivationV2Error::UnsupportedChain { + chain: "TRON".to_string(), + feature: "NFT".to_string(), + }); + } + let priv_key_policy = eth_priv_key_build_policy(&ctx, &activation_request.platform_request.priv_key_policy).await?; @@ -330,6 +338,14 @@ impl PlatformCoinWithTokensActivationOps for EthCoin { &self, activation_request: &Self::ActivationRequest, ) -> Result, MmError> { + // TRON doesn't support NFTs (safety check - should be caught earlier in enable_platform_coin) + if matches!(self.chain_spec, ChainSpec::Tron { .. }) && activation_request.nft_req.is_some() { + return MmError::err(EthActivationV2Error::UnsupportedChain { + chain: "TRON".to_string(), + feature: "NFT".to_string(), + }); + } + let (url, proxy_auth) = match &activation_request.nft_req { Some(nft_req) => match &nft_req.provider { NftProviderEnum::Moralis { url, komodo_proxy } => (url, *komodo_proxy), @@ -378,6 +394,7 @@ impl PlatformCoinWithTokensActivationOps for EthCoin { match self.derivation_method() { DerivationMethod::SingleAddress(my_address) => { + let my_address_formatted = my_address.display_address(); let pubkey = self.get_public_key().await.map_mm_err()?; let mut eth_address_info = CoinAddressInfo { derivation_method: self.derivation_method().to_response().await.map_mm_err()?, @@ -401,8 +418,8 @@ impl PlatformCoinWithTokensActivationOps for EthCoin { return Ok(EthWithTokensActivationResult::Iguana( IguanaEthWithTokensActivationResult { current_block, - eth_addresses_infos: HashMap::from([(my_address.display_address(), eth_address_info)]), - erc20_addresses_infos: HashMap::from([(my_address.display_address(), erc20_address_info)]), + eth_addresses_infos: HashMap::from([(my_address_formatted.clone(), eth_address_info)]), + erc20_addresses_infos: HashMap::from([(my_address_formatted, erc20_address_info)]), nfts_infos: nfts_map, }, )); @@ -426,8 +443,8 @@ impl PlatformCoinWithTokensActivationOps for EthCoin { Ok(EthWithTokensActivationResult::Iguana( IguanaEthWithTokensActivationResult { current_block, - eth_addresses_infos: HashMap::from([(my_address.display_address(), eth_address_info)]), - erc20_addresses_infos: HashMap::from([(my_address.display_address(), erc20_address_info)]), + eth_addresses_infos: HashMap::from([(my_address_formatted.clone(), eth_address_info)]), + erc20_addresses_infos: HashMap::from([(my_address_formatted, erc20_address_info)]), nfts_infos: nfts_map, }, )) diff --git a/mm2src/coins_activation/src/init_erc20_token_activation.rs b/mm2src/coins_activation/src/init_erc20_token_activation.rs index c34ce9fa4c..175f68a64d 100644 --- a/mm2src/coins_activation/src/init_erc20_token_activation.rs +++ b/mm2src/coins_activation/src/init_erc20_token_activation.rs @@ -204,11 +204,13 @@ impl InitTokenActivationOps for EthCoin { let token_contract_address = self .erc20_token_address() .ok_or_else(|| EthTokenActivationError::InternalError("Token contract address is missing".to_string()))?; + // Format contract address chain-aware: EVM checksum (0x) or TRON Base58 (T...) + let token_contract_address = self.format_raw_address(token_contract_address); Ok(InitTokenActivationResult { ticker, platform_coin: self.platform_ticker().to_owned(), - token_contract_address: format!("{token_contract_address:#02x}"), + token_contract_address, current_block, required_confirmations: self.required_confirmations(), wallet_balance, diff --git a/mm2src/coins_activation/src/lightning_activation.rs b/mm2src/coins_activation/src/lightning_activation.rs index 97c59bc9ff..682ee5a343 100644 --- a/mm2src/coins_activation/src/lightning_activation.rs +++ b/mm2src/coins_activation/src/lightning_activation.rs @@ -33,7 +33,7 @@ use lightning_invoice::payment; use mm2_core::mm_ctx::MmArc; use mm2_err_handle::prelude::*; use parking_lot::Mutex as PaMutex; -use secp256k1::Secp256k1; +use secp256k1v24::Secp256k1; use ser_error_derive::SerializeErrorType; use serde_derive::{Deserialize, Serialize}; use serde_json::{self as json, Value as Json}; diff --git a/mm2src/common/AGENTS.md b/mm2src/common/AGENTS.md new file mode 100644 index 0000000000..362425b8f9 --- /dev/null +++ b/mm2src/common/AGENTS.md @@ -0,0 +1,220 @@ +# common β€” Shared Utilities + +> **Note:** Always follow the root `/CLAUDE.md` for global conventions (fmt, clippy, error handling, etc.). + +Foundation crate providing utilities used across all KDF crates. Platform-aware (native/WASM). + +## Responsibilities + +- Async task execution and abortion (`executor`) +- Logging infrastructure (native and WASM) +- Time utilities (`now_ms`, `now_sec`, `get_utc_timestamp`) +- RPC response helpers (`HyRes`, `rpc_response`) +- Platform-specific abstractions +- Shared constants and types + +## Module Structure + +``` +β”œβ”€β”€ common.rs # Main module, re-exports, helpers +β”œβ”€β”€ log.rs # Logging (includes log/ submodule) +β”œβ”€β”€ executor/ # Async task management +β”‚ β”œβ”€β”€ mod.rs # spawn, Timer, AbortSettings +β”‚ β”œβ”€β”€ native_executor.rs # Tokio-based (native) +β”‚ β”œβ”€β”€ wasm_executor.rs # Browser-based (WASM) +β”‚ β”œβ”€β”€ abortable_system/ # AbortableQueue, graceful shutdown +β”‚ └── spawner.rs # SpawnFuture trait +β”œβ”€β”€ log/ # Logging implementations +β”‚ β”œβ”€β”€ native_log.rs # File/stdout logging +β”‚ └── wasm_log.rs # console.log +β”œβ”€β”€ custom_futures/ # Future utilities (timeout, repeatable) +β”œβ”€β”€ jsonrpc_client.rs # JSON-RPC macros +β”œβ”€β”€ password_policy.rs # Password validation +β”œβ”€β”€ crash_reports.rs # Panic/signal handlers +β”œβ”€β”€ wio.rs / wasm.rs # Platform I/O +└── write_safe/ # Safe write abstractions +``` + +## Executor Module + +### Spawning Tasks + +```rust +use common::executor::{spawn, Timer, SpawnFuture}; + +// Spawn fire-and-forget task +spawn(async { /* ... */ }); + +// Spawn with abort handle +let handle = spawn_abortable(async { /* ... */ }); +drop(handle); // Aborts the task + +// Sleep +Timer::sleep(1.5).await; // 1.5 seconds +``` + +### AbortableQueue + +Manages groups of tasks that can be aborted together: + +```rust +use common::executor::abortable_queue::AbortableQueue; + +let queue = AbortableQueue::default(); +let spawner = queue.weak_spawner(); + +// Spawn task that will be aborted when queue is dropped +spawner.spawn(async { /* ... */ }); + +// Graceful shutdown +queue.abort_all().await; +``` + +### SpawnFuture Trait + +Abstraction for spawning futures: + +```rust +pub trait SpawnFuture: Send + Sync + 'static { + fn spawn(&self, f: F) where F: Future + Send + 'static; +} +``` + +## Logging + +### Macros + +```rust +use common::log; + +log::info!("Message"); +log::error!("Error: {}", err); +log::debug!("Debug info"); + +// Human-readable log (adds location) +log!("Custom message"); +``` + +### Custom Log Callback + +```rust +use common::log::{register_callback, LogCallback}; + +// Set custom handler (e.g., for GUI) +register_callback(my_callback); +``` + +## Time Utilities + +```rust +use common::{now_ms, now_sec, wait_until_ms, get_utc_timestamp}; + +let timestamp_ms = now_ms(); // Current time in milliseconds +let timestamp_sec = now_sec(); // Current time in seconds +let deadline = wait_until_ms(5000); // 5 seconds from now +let utc = get_utc_timestamp(); // UTC timestamp (i64) +``` + +## RPC Helpers + +```rust +use common::{rpc_response, rpc_err_response, HyRes, HttpStatusCode}; + +// Success response +fn handler() -> HyRes { + rpc_response(200, json!({"result": "ok"}).to_string()) +} + +// Error response (logs automatically) +fn error_handler() -> HyRes { + rpc_err_response(400, "Invalid request") +} + +// HttpStatusCode trait for errors +impl HttpStatusCode for MyError { + fn status_code(&self) -> StatusCode { ... } +} +``` + +## Constants + +```rust +// DEX fee addresses +pub const DEX_FEE_ADDR_PUBKEY: &str = "0348685437..."; +pub const DEX_BURN_ADDR_PUBKEY: &str = "0348685437..."; // Same as fee (burn disabled) +// Satoshis conversion +pub const SATOSHIS: u64 = 100_000_000; +pub fn sat_to_f(sat: u64) -> f64; +``` + +## Platform Macros + +```rust +// Conditional compilation helpers +cfg_native! { + // Native-only code +} + +cfg_wasm32! { + // WASM-only code +} +``` + +## Useful Types + +| Type | Purpose | +|------|---------| +| `bits256` | 32-byte hash type with hex display | +| `HyRes` | Legacy RPC response future | +| `SuccessResponse` | Standard `{"result": "success"}` | +| `PagingOptions` | Pagination parameters | +| `OrdRange` | Ordered inclusive range | + +## Key Functions + +| Function | Purpose | +|----------|---------| +| `block_on(future)` | Block on async (native only) | +| `small_rng()` | Seeded random number generator | +| `os_rng(&mut buf)` | Cryptographic random bytes | +| `median(&mut slice)` | Calculate median value | +| `sha256_digest(path)` | File hash | +| `kdf_app_dir()` | Application directory path | + +## Interactions + +This crate is imported by virtually all other crates in the workspace: + +| Crate | Usage | +|-------|-------| +| **All crates** | Logging, time, executor utilities | +| **mm2_core** | MmCtx uses AbortableQueue for task management | +| **mm2_main** | RPC handlers use response helpers | +| **mm2_p2p** | SpawnFuture trait for async tasks | +| **coins** | Time utilities, DEX fee constants | + +## Sub-Crate + +- **shared_ref_counter** (`common/shared_ref_counter/`) β€” Debug-instrumented Arc alternative with optional allocation site tracking (enable with `enable` feature) + +## Platform Differences + +| Feature | Native | WASM | +|---------|--------|------| +| `spawn()` | Tokio runtime | `wasm_bindgen_futures` | +| `Timer::sleep()` | `tokio::time` | `setTimeout` | +| `block_on()` | Works | Panics | +| `LOG_FILE` | File output | N/A | +| `writeln()` | stdout/file | `console.log` | + +## Common Pitfalls + +| Issue | Solution | +|-------|----------| +| `block_on` in WASM | Use `.await` instead | +| Missing logs | Check `MM_LOG` env var | +| Task not aborting | Use `spawn_abortable` or `AbortableQueue` | + +## Tests + +- Unit: `cargo test -p common --lib` diff --git a/mm2src/common/Cargo.toml b/mm2src/common/Cargo.toml index 640de61fc3..79679f62d6 100644 --- a/mm2src/common/Cargo.toml +++ b/mm2src/common/Cargo.toml @@ -17,6 +17,7 @@ track-ctx-pointer = ["shared_ref_counter/enable", "shared_ref_counter/log"] arrayref.workspace = true async-trait.workspace = true backtrace.workspace = true +base64.workspace = true bytes.workspace = true cfg-if.workspace = true compatible-time.workspace = true diff --git a/mm2src/common/common.rs b/mm2src/common/common.rs index a8a3456bc5..4d78c08351 100644 --- a/mm2src/common/common.rs +++ b/mm2src/common/common.rs @@ -220,10 +220,12 @@ pub const APPLICATION_GRPC_WEB_TEXT_PROTO: &str = "application/grpc-web-text+pro pub const SATOSHIS: u64 = 100_000_000; /// Dex fee public key for chains where SECP256K1 is supported -pub const DEX_FEE_ADDR_PUBKEY: &str = "03bc2c7ba671bae4a6fc835244c9762b41647b9827d4780a89a949b984a8ddcc06"; +pub const DEX_FEE_ADDR_PUBKEY: &str = "0348685437335ec43ba6211caf848576ca3d34abbe9e089f861471b4ed9ee9bbd1"; /// Public key to collect the burn part of dex fee, for chains where SECP256K1 is supported -pub const DEX_BURN_ADDR_PUBKEY: &str = "0369aa10c061cd9e085f4adb7399375ba001b54136145cb748eb4c48657be13153"; +/// Burn currently disabled - using same address as fee address in case there is a bug that enables burn (can be re-enabled later) +pub const DEX_BURN_ADDR_PUBKEY: &str = "0348685437335ec43ba6211caf848576ca3d34abbe9e089f861471b4ed9ee9bbd1"; +// TODO: Update ED25519 pubkey for Sia when GUI support is added pub const DEX_FEE_PUBKEY_ED25519: &str = "77b0936728f63257b074c7b3fb2c4fad98df345f57de1ec418fc42619e4e29f8"; pub const PROXY_REQUEST_EXPIRATION_SEC: i64 = 15; diff --git a/mm2src/common/seri.rs b/mm2src/common/seri.rs index 59f90b2c5e..918c983da8 100644 --- a/mm2src/common/seri.rs +++ b/mm2src/common/seri.rs @@ -1,4 +1,7 @@ -use serde::de::{self, Deserializer}; +use base64::engine::general_purpose::STANDARD as BASE64_ENGINE; +use base64::Engine; +use serde::de; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::fmt; /// Deserializes an empty string into `None`. @@ -39,3 +42,13 @@ pub fn de_none_if_empty<'de, D: Deserializer<'de>>(des: D) -> Result>(d: D) -> Result, D::Error> { + let base64 = String::deserialize(d)?; + BASE64_ENGINE.decode(base64).map_err(serde::de::Error::custom) +} + +pub fn serialize_base64(v: &Vec, s: S) -> Result { + let base64 = BASE64_ENGINE.encode(v); + String::serialize(&base64, s) +} diff --git a/mm2src/crypto/AGENTS.md b/mm2src/crypto/AGENTS.md new file mode 100644 index 0000000000..1991bbe092 --- /dev/null +++ b/mm2src/crypto/AGENTS.md @@ -0,0 +1,185 @@ +# crypto β€” Key Management and HD Derivation + +> **Note:** Always follow the root `/CLAUDE.md` for global conventions (fmt, clippy, error handling, etc.). + +**Security-critical crate.** Handles mnemonics, seeds, key derivation, and hardware wallet integration. + +## Security Rules (Non-Negotiable) + +1. **NEVER log**: mnemonics, seeds, private keys, extended keys +2. **NEVER serialize** sensitive data in error messages +3. **Zeroize** secrets on drop (use `zeroize` crate) +4. **Validate** all derivation paths before use + +## Responsibilities + +- Cryptographic context management (`CryptoCtx`) +- BIP39/BIP32/SLIP-10/SLIP-21 HD derivation (`GlobalHDAccountCtx`) +- Key policy detection and enforcement +- Hardware wallet context (Trezor, MetaMask) +- Mnemonic encryption/decryption +- Secret hash algorithm selection for swaps + +## Core Types + +### CryptoCtx (crypto_ctx.rs) + +Central crypto context stored in `MmArc`. + +```rust +pub enum KeyPairPolicy { + Iguana, // Single key from passphrase + GlobalHDAccount(GlobalHDAccountArc), // BIP39 HD wallet +} +``` + +**Access patterns:** +```rust +CryptoCtx::is_init(&ctx)?; // Check initialized +let crypto = CryptoCtx::from_ctx(&ctx)?; // Get context +``` + +**Key access methods:** +- `mm2_internal_key_pair()` β€” Internal mm2 keypair +- `mm2_internal_pubkey()` β€” Public key for P2P +- `mm2_internal_public_id()` β€” 32-byte public ID +- `hw_wallet_rmd160()` β€” Hardware wallet address hash (if HW active) + +### GlobalHDAccountCtx (global_hd_ctx.rs) + +HD wallet context. **Internal state is never exposed.** + +```rust +// Initialization (happens once at startup) +let (keypair, hd_ctx) = GlobalHDAccountCtx::new(mnemonic)?; +``` + +**Derivation methods:** +```rust +// secp256k1 (BIP32) β€” Bitcoin, Ethereum, etc. +let secret = hd_ctx.derive_secp256k1_secret(&path)?; + +// ed25519 (SLIP-10) β€” Solana, etc. +let key = hd_ctx.derive_ed25519_signing_key(&path)?; +``` + +### PrivKeyBuildPolicy (in coins crate) + +Determines key source during coin activation. Defined in `coins/lp_coins.rs`: + +```rust +pub enum PrivKeyBuildPolicy { + IguanaPrivKey(IguanaPrivKey), + GlobalHDAccount(GlobalHDAccountArc), + Trezor, + WalletConnect { session_topic }, +} + +// Auto-detect from context +let policy = PrivKeyBuildPolicy::detect_priv_key_policy(&ctx)?; +``` + +## BIP44 Derivation Paths + +``` +m / purpose' / coin_type' / account' / change / address_index +m / 44' / 60' / 0' / 0 / 0 (ETH first address) +m / 44' / 141' / 0' / 0 / 0 (KMD first address) +``` + +**Path types:** +- `HDPathToCoin`: Coin-level path (purpose + coin_type) +- `HDPathToAccount`: Account-level path (purpose + coin_type + account_id) +- `DerivationPath`: Full path including address index + +## Hardware Wallets + +### Trezor (Native Only) +```rust +#[cfg(not(target_arch = "wasm32"))] +let crypto_ctx = CryptoCtx::from_ctx(&ctx)?; +crypto_ctx.init_hw_ctx_with_trezor(processor, expected_pubkey).await?; +``` + +### MetaMask (WASM Only) +```rust +#[cfg(target_arch = "wasm32")] +let crypto_ctx = CryptoCtx::from_ctx(&ctx)?; +crypto_ctx.init_metamask_ctx(project_name).await?; +``` + +## Common Patterns + +### Deriving Coin Keys +```rust +// During coin activation: +let path = coin_conf.derivation_path()?; +let secret = hd_ctx.derive_secp256k1_secret(&path)?; +let keypair = key_pair_from_secret(&secret)?; +``` + +### Checking HD Mode +```rust +if ctx.enable_hd() { + // HD wallet mode +} else { + // Iguana legacy mode +} +``` + +## Interactions + +| Crate | Usage | +|-------|-------| +| **coins** | Coin builders use `PrivKeyBuildPolicy` | +| **mm2_core** | `CryptoCtx` stored in `MmArc` | +| **trezor** | Hardware wallet integration | +| **mm2_metamask** | MetaMask WASM integration | +| **mm2_err_handle** | MmError framework | +| **hw_common** | Hardware wallet abstractions | +| **rpc_task** | Task-based hardware wallet flows | + +## Error Types + +```rust +pub enum CryptoCtxError { + NotInitialized, + Internal(String), +} + +pub enum CryptoInitError { + NotInitialized, + InitializedAlready, + EmptyPassphrase, + InvalidPassphrase(PrivKeyError), + Internal(String), +} +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `crypto_ctx.rs` | CryptoCtx, KeyPairPolicy | +| `global_hd_ctx.rs` | GlobalHDAccountCtx, derivation | +| `privkey.rs` | Key generation from seed | +| `hw_ctx.rs` | Hardware wallet context | +| `hw_client.rs` | Hardware wallet client traits | +| `hw_error.rs` | Hardware wallet error types | +| `hw_rpc_task.rs` | Hardware wallet RPC task types | +| `metamask_ctx.rs` | MetaMask context (WASM) | +| `metamask_login.rs` | MetaMask login request types (WASM) | +| `mnemonic.rs` | BIP39 mnemonic handling | +| `encrypt.rs` / `decrypt.rs` | Mnemonic encryption | +| `secret_hash_algo.rs` | Swap secret hash algorithm | +| `slip21.rs` | SLIP-21 symmetric key derivation | +| `standard_hd_path.rs` | BIP44 path types (StandardHDPath, HDPathToCoin) | +| `bip32_child.rs` | BIP32 derivation path building blocks | +| `shared_db_id.rs` | Database namespace derivation | +| `xpub.rs` | Extended public key handling | +| `key_derivation.rs` | Key derivation utilities | + +## Tests + +- Unit: `cargo test -p crypto --lib` +- Integration: HD wallet tests in `mm2_main/tests/` diff --git a/mm2src/derives/enum_derives/src/lib.rs b/mm2src/derives/enum_derives/src/lib.rs index 18d55e912a..854030a732 100644 --- a/mm2src/derives/enum_derives/src/lib.rs +++ b/mm2src/derives/enum_derives/src/lib.rs @@ -144,7 +144,6 @@ pub fn derive(input: TokenStream) -> TokenStream { /// Polygon, /// } /// -///#[test] ///fn test_enum_variant_list() { /// let all_chains = Chain::variant_list(); /// assert_eq!(all_chains, vec![ diff --git a/mm2src/mm2_bin_lib/AGENTS.md b/mm2src/mm2_bin_lib/AGENTS.md new file mode 100644 index 0000000000..7e5b6d95f7 --- /dev/null +++ b/mm2src/mm2_bin_lib/AGENTS.md @@ -0,0 +1,218 @@ +# mm2_bin_lib β€” Platform Entry Points + +> **Note:** Always follow the root `/CLAUDE.md` for global conventions (fmt, clippy, error handling, etc.). + +Thin wrapper providing platform-specific entry points for KDF. Bridges native, WASM, and mobile platforms to `mm2_main`. + +## Responsibilities + +- Platform-specific initialization (native/WASM/mobile) +- Singleton instance management (`LP_MAIN_RUNNING`) +- FFI/WASM bindings for external callers +- Lifecycle control (start, status, stop) +- Version information exposure + +## Module Structure + +``` +src/ +β”œβ”€β”€ lib.rs # Shared types, status checking, stop logic +β”œβ”€β”€ mm2_bin.rs # Native binary entry point (main()) +β”œβ”€β”€ mm2_native_lib.rs # C FFI for mobile/embedded (native only) +└── mm2_wasm_lib.rs # WASM bindings (wasm32 only) +``` + +## Entry Points by Platform + +| Platform | Entry Point | File | +|----------|-------------|------| +| Native CLI | `main()` | `mm2_bin.rs` | +| iOS/Android | `mm2_main(conf, log_cb)` | `mm2_native_lib.rs` | +| WASM | `mm2_main(params, log_cb)` | `mm2_wasm_lib.rs` | + +## Core Types + +### MainStatus + +Status of the MM2 singleton: + +```rust +pub enum MainStatus { + NotRunning = 0, // MM2 not started + NoContext = 1, // Running, no MmCtx yet + NoRpc = 2, // Context exists, RPC not ready + RpcIsUp = 3, // Fully operational +} +``` + +### StartupResultCode + +Initialization result: + +```rust +pub enum StartupResultCode { + Ok = 0, + InvalidParams = 1, + ConfigError = 2, + AlreadyRunning = 3, + InitError = 4, + SpawnError = 5, +} +``` + +### StopStatus + +Shutdown result: + +```rust +pub enum StopStatus { + Ok = 0, + NotRunning = 1, + ErrorStopping = 2, + StoppingAlready = 3, +} +``` + +## Native Library API (C FFI) + +For mobile/embedded integration: + +```c +// Start MM2 with JSON config and log callback +int8_t mm2_main(const char* conf, void (*log_cb)(const char*)); + +// Check running status (returns MainStatus) +int8_t mm2_main_status(); + +// Stop MM2 instance +int8_t mm2_stop(); + +// Run embedded tests +int32_t mm2_test(int32_t torch, void (*log_cb)(const char*)); +``` + +## WASM API + +For browser integration: + +```typescript +// Start MM2 +async function mm2_main(params: MainParams, log_cb: Function): Promise; + +// Check status +function mm2_main_status(): MainStatus; + +// Make RPC call +async function mm2_rpc(payload: object): Promise; + +// Get version (works before mm2 starts) +function mm2_version(): { result: string, datetime: string }; + +// Stop MM2 +async function mm2_stop(): Promise; +``` + +### WASM Usage Example + +```javascript +import init, { mm2_main, mm2_rpc, LogLevel } from "./kdflib.js"; + +const params = { + conf: { + gui: "WASMTEST", + mm2: 1, + passphrase: "...", + rpc_password: "test123", + coins: [{ coin: "ETH", protocol: { type: "ETH" }}] + }, + log_level: LogLevel.Info, +}; + +await mm2_main(params, (level, line) => console.log(line)); + +const version = await mm2_rpc({ userpass: "test123", method: "version" }); +``` + +## Initialization Flow + +### Native +1. `main()` calls `mm2_main::mm2_main(version, datetime)` +2. Config loaded from CLI args or `MM2.json` +3. `lp_main()` initializes `MmCtx` +4. `lp_run()` spawned in "lp_run" thread + +### Native Library (Mobile) +1. `mm2_main(conf, log_cb)` called via FFI +2. `init_crash_reports()` sets up panic handler +3. `register_callback()` configures logging +4. `block_on(lp_main())` initializes context +5. Thread spawned for `block_on(lp_run())` +6. Returns immediately, MM2 runs in background + +### WASM +1. `mm2_main(params, log_cb)` called from JS +2. `set_panic_hook()` for error logging +3. `await lp_main()` initializes context +4. `spawn_local(lp_run())` runs event loop +5. Returns after initialization + +## Singleton Management + +Global state ensures single instance: + +```rust +static LP_MAIN_RUNNING: AtomicBool = AtomicBool::new(false); +static CTX: AtomicU32 = AtomicU32::new(0); // FFI handle to MmArc +``` + +- `LP_MAIN_RUNNING`: Guards against multiple starts +- `CTX`: Stores context handle for status/stop operations + +## Interactions + +| Crate | Usage | +|-------|-------| +| **mm2_main** | Core logic (`lp_main`, `lp_run`) | +| **mm2_core** | `MmArc` context management | +| **common** | Logging, crash reports, executor | +| **mm2_rpc** | WASM RPC bridge (wasm_rpc) | + +## Platform-Specific Notes + +### Native +- Uses jemalloc on Linux x86_64 GNU +- Spawns separate "lp_run" thread +- Supports `MM2.json` config file + +### WASM +- Single-threaded (`spawn_local`) +- No `block_on` (panics) +- RPC via `mm2_rpc()` function, not HTTP + +### Mobile +- Compiled as static library (`libkdf.a`) +- Log callback required for output +- Same API as native library + +## Common Pitfalls + +| Issue | Solution | +|-------|----------| +| "Already running" | Call `mm2_stop()` first | +| Status stuck at NoRpc | Wait for initialization to complete | +| WASM panic | Check browser console, `set_panic_hook` logs | +| Mobile logs missing | Ensure log callback is provided | + +## Build Outputs + +| Target | Output | +|--------|--------| +| Native | `kdf` binary | +| WASM | `kdflib_bg.wasm` + `kdflib.js` | +| iOS | `libkdf.a` (static library) | +| Android | `libkdf.a` (static library) | + +## Tests + +- Native: Run via `mm2_test()` FFI function +- WASM: Browser-based via `wasm-pack test` diff --git a/mm2src/mm2_bin_lib/Cargo.toml b/mm2src/mm2_bin_lib/Cargo.toml index 4ee2fbac4a..534054d8a6 100644 --- a/mm2src/mm2_bin_lib/Cargo.toml +++ b/mm2src/mm2_bin_lib/Cargo.toml @@ -5,7 +5,7 @@ [package] name = "mm2_bin_lib" -version = "2.6.0-beta" +version = "3.0.0-beta" authors = ["James Lee", "Artem Pikulin", "Artem Grinblat", "Omar S.", "Onur Ozkan", "Alina Sharon", "Caglar Kaya", "Cipi", "Sergey Boiko", "Samuel Onoja", "Roman Sztergbaum", "Kadan Stadelmann ", "Dimxy", "Omer Yacine", "DeckerSU", "dragonhound ", "Guru Charan Gupta", "Devin AI"] edition = "2018" default-run = "kdf" @@ -36,7 +36,9 @@ common = { path = "../common" } enum-primitive-derive.workspace = true libc.workspace = true mm2_core = { path = "../mm2_core" } -mm2_main = { path = "../mm2_main" } +# utxo-walletconnect is safe here: WASM library builds don't fully link like test binaries, +# so duplicate secp256k1-sys symbols don't cause conflicts. +mm2_main = { path = "../mm2_main", features = ["utxo-walletconnect"] } num-traits.workspace = true serde_json = { workspace = true, features = ["preserve_order", "raw_value"] } diff --git a/mm2src/mm2_bitcoin/AGENTS.md b/mm2src/mm2_bitcoin/AGENTS.md new file mode 100644 index 0000000000..1bb79ef7b5 --- /dev/null +++ b/mm2src/mm2_bitcoin/AGENTS.md @@ -0,0 +1,292 @@ +# mm2_bitcoin β€” UTXO Primitives + +> **Note:** Always follow the root `/CLAUDE.md` for global conventions (fmt, clippy, error handling, etc.). + +Low-level primitives for all UTXO-based coins (Bitcoin, Komodo, Litecoin, etc.). Named "bitcoin" historically but used across all UTXO protocols. + +**Note:** This is a workspace of sub-crates, not a single crate. Each sub-crate is published separately. + +## Responsibilities + +- Cryptographic hash functions (SHA256, RIPEMD160, Groestl, Keccak) +- Key management (private, public, keypairs) +- Address encoding (Legacy, SegWit, CashAddress) +- Transaction structures and serialization +- Script building and signing +- SPV validation and proof verification +- Block header handling + +## Sub-Crate Structure + +``` +mm2_bitcoin/ +β”œβ”€β”€ primitives/ # Core types: H160, H256, U256, bytes, compact +β”œβ”€β”€ crypto/ # Hash functions (crate: bitcrypto) +β”œβ”€β”€ keys/ # Address and key management +β”œβ”€β”€ chain/ # Block and transaction structures +β”œβ”€β”€ script/ # Bitcoin scripting language +β”œβ”€β”€ serialization/ # Binary encoding/decoding +β”œβ”€β”€ serialization_derive/ # Derive macros for serialization +β”œβ”€β”€ rpc/ # RPC response types +β”œβ”€β”€ spv_validation/ # SPV proof verification +└── test_helpers/ # Testing utilities +``` + +## primitives + +Core data types used throughout: + +```rust +// Hash types +pub struct H160([u8; 20]); // RIPEMD160, address hash +pub struct H256([u8; 32]); // SHA256, tx/block hash +pub struct H512([u8; 64]); // Groestl512 + +// Big integer for difficulty +pub struct U256(4); // 256-bit unsigned + +// Compact difficulty representation +pub struct Compact(u32); +``` + +## crypto (bitcrypto) + +Cryptographic hash functions: + +| Function | Output | Usage | +|----------|--------|-------| +| `sha256(data)` | H256 | Single SHA256 | +| `dhash256(data)` | H256 | Double SHA256 (Bitcoin standard) | +| `ripemd160(data)` | H160 | RIPEMD160 | +| `dhash160(data)` | H160 | SHA256 + RIPEMD160 (address hash) | +| `groestl512(data)` | H512 | Groestl (GRS) | +| `keccak256(data)` | H256 | Keccak (SMART) | +| `checksum(data, type)` | H32 | 4-byte checksum | + +Checksum variants: +- `DSHA256` β€” Most coins (Bitcoin default) +- `DGROESTL512` β€” Groestlcoin +- `KECCAK256` β€” SmartCash + +## keys + +Address and key management: + +### Address Types +```rust +pub enum AddressFormat { + Standard, // Legacy P2PKH/P2SH + Segwit, // Native SegWit (bech32) + CashAddress { network, .. }, // Bitcoin Cash format (with network prefix) +} + +// Simplified view (actual struct has more internal fields) +pub struct Address { + pub addr_format: AddressFormat, + pub script_type: AddressScriptType, + pub hash: AddressHashEnum, + // Internal: prefix, hrp, checksum_type, pubkey +} +``` + +### Key Types +```rust +pub struct Private { /* 32-byte secret */ } +pub struct Public { /* 33 or 65 byte pubkey */ } +pub struct KeyPair { private: Private, public: Public } +pub type Secret = H256; +pub type Message = H256; +``` + +### Address Hash +```rust +pub enum AddressHashEnum { + AddressHash(H160), // P2PKH, P2SH, P2WPKH + WitnessScriptHash(H256), // P2WSH +} +``` + +## chain + +Block and transaction structures. **Note:** Actual structs have many additional optional fields for coin-specific features (Zcash shielded, Qtum PoS, AuxPow, etc.). + +```rust +// Block header (core fields, actual struct has ~20 optional fields) +pub struct BlockHeader { + pub version: u32, + pub previous_header_hash: H256, + pub merkle_root_hash: H256, + pub time: u32, + pub bits: BlockHeaderBits, + pub nonce: BlockHeaderNonce, + // Optional: claim_trie_root, hash_final_sapling_root, solution, + // aux_pow, prog_pow, mtp_pow, hash_state_root, etc. +} + +// Transaction output reference +pub struct OutPoint { + pub hash: H256, + pub index: u32, +} + +// Full transaction (core fields, actual struct has ~15 optional fields) +pub struct Transaction { + pub version: i32, + pub inputs: Vec, + pub outputs: Vec, + pub lock_time: u32, + // Optional: n_time, overwintered, expiry_height, shielded_spends, + // shielded_outputs, join_splits, binding_sig, etc. +} +``` + +## script + +Bitcoin scripting: + +```rust +// Build scripts +let script = Builder::default() + .push_opcode(Opcode::OP_DUP) + .push_opcode(Opcode::OP_HASH160) + .push_bytes(&pubkey_hash) + .push_opcode(Opcode::OP_EQUALVERIFY) + .push_opcode(Opcode::OP_CHECKSIG) + .into_script(); + +// Script types +pub enum ScriptType { + NonStandard, + PubKey, + PubKeyHash, + ScriptHash, + Multisig, + NullData, + WitnessScript, + WitnessKey, + Taproot, + CallSender, // Qtum + CreateSender, // Qtum +} + +// Transaction signing +pub struct TransactionInputSigner { /* ... */ } +pub enum SignatureVersion { Base, WitnessV0, ForkId } +``` + +## serialization + +Binary encoding for network protocol: + +```rust +// Serialize to bytes +let bytes: Vec = serialize(&transaction); + +// Deserialize from bytes +let tx: Transaction = deserialize(&bytes)?; + +// Compact integer encoding +pub struct CompactInteger(u64); + +// Streaming +let mut stream = Stream::new(); +transaction.serialize(&mut stream); +``` + +## spv_validation + +Simplified Payment Verification: + +```rust +// SPV configuration +pub struct SPVConf { + pub starting_block_header: SPVBlockHeader, + pub max_stored_block_headers: Option, + pub validation_params: Option, +} + +// Storage trait for headers +#[async_trait] +pub trait BlockHeaderStorageOps { + async fn init(&self) -> Result<(), BlockHeaderStorageError>; + async fn add_block_headers_to_storage(&self, headers: HashMap) -> Result<()>; + async fn get_block_header(&self, height: u64) -> Result>; + async fn get_last_block_height(&self) -> Result>; +} +``` + +## Interactions + +| Crate | Usage | +|-------|-------| +| **coins/utxo** | Primary consumer for UTXO coin implementations | +| **coins/z_coin** | Zcash uses extended transaction types | +| **mm2_main/lp_swap** | Transaction building for atomic swaps | +| **utxo_signer** | UTXO transaction signing | +| **trezor** | Key types for hardware wallet signing | +| **crypto** | Key pair types, address hashing | + +## Coin-Specific Variants + +| Coin Type | Hash | Address | Notes | +|-----------|------|---------|-------| +| Bitcoin | DSHA256 | Legacy/SegWit | Standard | +| Komodo | DSHA256 | Legacy | Zcash-derived | +| Litecoin | DSHA256 | Legacy/SegWit | Different prefixes | +| Bitcoin Cash | DSHA256 | CashAddress | Different format | +| Groestlcoin | DGROESTL512 | Legacy/SegWit | Different hash | +| SmartCash | KECCAK256 | Legacy | Different hash | +| Zcash | DSHA256 | Legacy | Shielded extensions | + +## Common Patterns + +### Creating Address from Public Key +```rust +let pubkey_hash = dhash160(&public_key.serialize()); +let address = AddressBuilder::new( + AddressFormat::Standard, + ChecksumType::DSHA256, + prefixes, // NetworkAddressPrefixes + None, // hrp for segwit +) + .as_pkh(AddressHashEnum::AddressHash(pubkey_hash)) + .build()?; +``` + +### Signing Transaction +```rust +// Create signer from transaction +let signer: TransactionInputSigner = transaction.into(); + +// Get signature hash +let sighash = signer.signature_hash( + input_index, + input_amount, + &script_pubkey, + SignatureVersion::Base, + SIGHASH_ALL, +); + +// Sign with keypair +let signed_input = signer.signed_input( + &keypair, + input_index, + input_amount, + &script_pubkey, + SignatureVersion::Base, + SIGHASH_ALL, +); +``` + +## Tests + +Each sub-crate has its own tests: +```bash +cargo test -p primitives +cargo test -p bitcrypto +cargo test -p keys +cargo test -p chain +cargo test -p script +cargo test -p serialization +cargo test -p spv_validation +``` diff --git a/mm2src/mm2_bitcoin/chain/Cargo.toml b/mm2src/mm2_bitcoin/chain/Cargo.toml index c13290a413..466d4fa9b7 100644 --- a/mm2src/mm2_bitcoin/chain/Cargo.toml +++ b/mm2src/mm2_bitcoin/chain/Cargo.toml @@ -7,12 +7,15 @@ edition = "2015" [lib] doctest = false +[features] +default = ["ext-bitcoin"] +# TODO: Unify secp256k1 versions across workspace (v0.20 vs v0.24) to remove ext-bitcoin feature. +ext-bitcoin = ["bitcoin"] + [dependencies] rustc-hex.workspace = true bitcrypto = { path = "../crypto" } primitives = { path = "../primitives" } serialization = { path = "../serialization" } serialization_derive = { path = "../serialization_derive" } - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -bitcoin.workspace = true +bitcoin = { workspace = true, optional = true } diff --git a/mm2src/mm2_bitcoin/chain/src/block_header.rs b/mm2src/mm2_bitcoin/chain/src/block_header.rs index 190441c1f7..ba04b42755 100644 --- a/mm2src/mm2_bitcoin/chain/src/block_header.rs +++ b/mm2src/mm2_bitcoin/chain/src/block_header.rs @@ -1,9 +1,6 @@ use compact::Compact; use crypto::dhash256; -#[cfg(not(target_arch = "wasm32"))] -use ext_bitcoin::blockdata::block::BlockHeader as ExtBlockHeader; -#[cfg(not(target_arch = "wasm32"))] -use ext_bitcoin::hash_types::{BlockHash as ExtBlockHash, TxMerkleNode as ExtTxMerkleNode}; + use hash::H256; use hex::FromHex; use primitives::bytes::Bytes; @@ -378,31 +375,9 @@ impl From<&'static str> for BlockHeader { } } -#[cfg(not(target_arch = "wasm32"))] -impl From for ExtBlockHeader { - fn from(header: BlockHeader) -> Self { - let prev_blockhash = ExtBlockHash::from_hash(header.previous_header_hash.to_sha256d()); - let merkle_root = ExtTxMerkleNode::from_hash(header.merkle_root_hash.to_sha256d()); - // note: H256 nonce is not supported for bitcoin, we will just set nonce to 0 in this case since this will never happen - let nonce = match header.nonce { - BlockHeaderNonce::U32(n) => n, - _ => 0, - }; - ExtBlockHeader { - version: header.version as i32, - prev_blockhash, - merkle_root, - time: header.time, - bits: header.bits.into(), - nonce, - } - } -} - #[cfg(test)] mod tests { - #[cfg(not(target_arch = "wasm32"))] - use super::ExtBlockHeader; + use block_header::{ BlockHeader, BlockHeaderBits, BlockHeaderNonce, BIP9_NO_SOFT_FORK_BLOCK_HEADER_VERSION, KAWPOW_VERSION, MTP_POW_VERSION, PROG_POW_SWITCH_TIME, @@ -2538,18 +2513,6 @@ mod tests { assert_eq!(serialized.take(), header_bytes); } - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn test_from_blockheader_to_ext_blockheader() { - // https://live.blockcypher.com/btc/block/00000000000000000020cf2bdc6563fb25c424af588d5fb7223461e72715e4a9/ - let header: BlockHeader = "0200000066720b99e07d284bd4fe67ff8c49a5db1dd8514fcdab610000000000000000007829844f4c3a41a537b3131ca992643eaa9d093b2383e4cdc060ad7dc548118751eb505ac1910018de19b302".into(); - let ext_header = ExtBlockHeader::from(header.clone()); - assert_eq!( - header.hash().reversed().to_string(), - ext_header.block_hash().to_string() - ); - } - #[test] fn test_ppc_block_headers() { // PeerCoin block 659052 has version 4, but it doesn't use Zcash format diff --git a/mm2src/mm2_bitcoin/chain/src/ext_bitcoin.rs b/mm2src/mm2_bitcoin/chain/src/ext_bitcoin.rs new file mode 100644 index 0000000000..a182048023 --- /dev/null +++ b/mm2src/mm2_bitcoin/chain/src/ext_bitcoin.rs @@ -0,0 +1,101 @@ +//! `bitcoin` crate interoperability for `mm2_bitcoin::chain`. +//! +//! This module is compiled only when the `ext-bitcoin` feature is enabled. +//! It centralizes all conversions between `chain` core types and the upstream +//! `bitcoin` crate types to keep feature-gated code out of the core modules. + +pub use bitcoin::blockdata::block::BlockHeader as BitcoinBlockHeader; +pub use bitcoin::blockdata::transaction::{ + OutPoint as BitcoinOutPoint, Transaction as BitcoinTransaction, TxIn as BitcoinTxIn, TxOut as BitcoinTxOut, +}; +pub use bitcoin::hash_types::{ + BlockHash as BitcoinBlockHash, TxMerkleNode as BitcoinTxMerkleNode, Txid as BitcoinTxid, +}; +pub use bitcoin::{PackedLockTime as BitcoinPackedLockTime, Sequence as BitcoinSequence, Witness as BitcoinWitness}; + +use block_header::{BlockHeader, BlockHeaderNonce}; +use transaction::{OutPoint, Transaction, TransactionInput, TransactionOutput}; + +impl From for BitcoinBlockHeader { + fn from(header: BlockHeader) -> Self { + let prev_blockhash = BitcoinBlockHash::from_hash(header.previous_header_hash.to_sha256d()); + let merkle_root = BitcoinTxMerkleNode::from_hash(header.merkle_root_hash.to_sha256d()); + // Note: H256 nonce is not supported for bitcoin, we will just set nonce to 0 in this case since this will never happen. + let nonce = match header.nonce { + BlockHeaderNonce::U32(n) => n, + _ => 0, + }; + BitcoinBlockHeader { + version: header.version as i32, + prev_blockhash, + merkle_root, + time: header.time, + bits: header.bits.into(), + nonce, + } + } +} + +impl From for BitcoinOutPoint { + fn from(outpoint: OutPoint) -> Self { + BitcoinOutPoint { + txid: BitcoinTxid::from_hash(outpoint.hash.to_sha256d()), + vout: outpoint.index, + } + } +} + +impl From for BitcoinTxIn { + fn from(txin: TransactionInput) -> Self { + BitcoinTxIn { + previous_output: txin.previous_output.into(), + script_sig: txin.script_sig.take().into(), + sequence: BitcoinSequence(txin.sequence), + witness: BitcoinWitness::from_vec(txin.script_witness.into_iter().map(|s| s.take()).collect()), + } + } +} + +impl From for BitcoinTxOut { + fn from(txout: TransactionOutput) -> Self { + BitcoinTxOut { + value: txout.value, + script_pubkey: txout.script_pubkey.take().into(), + } + } +} + +impl From for BitcoinTransaction { + fn from(tx: Transaction) -> Self { + BitcoinTransaction { + version: tx.version, + lock_time: BitcoinPackedLockTime(tx.lock_time), + input: tx.inputs.into_iter().map(|i| i.into()).collect(), + output: tx.outputs.into_iter().map(|o| o.into()).collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_blockheader_to_ext_blockheader() { + // https://live.blockcypher.com/btc/block/00000000000000000020cf2bdc6563fb25c424af588d5fb7223461e72715e4a9/ + let header: BlockHeader = "0200000066720b99e07d284bd4fe67ff8c49a5db1dd8514fcdab610000000000000000007829844f4c3a41a537b3131ca992643eaa9d093b2383e4cdc060ad7dc548118751eb505ac1910018de19b302".into(); + let ext_header = BitcoinBlockHeader::from(header.clone()); + assert_eq!( + header.hash().reversed().to_string(), + ext_header.block_hash().to_string() + ); + } + + #[test] + fn test_from_tx_to_ext_tx() { + // https://live.blockcypher.com/btc-testnet/tx/2be90e03abb4d5328bf7e9467ca9c571aef575837b55f1253119b87e85ccb94f/ + let tx: Transaction = "010000000001016546e6d844ad0142c8049a839e8deae16c17f0a6587e36e75ff2181ed7020a800100000000ffffffff0247070800000000002200200bbfbd271853ec0a775e5455d4bb19d32818e9b5bda50655ac183fb15c9aa01625910300000000001600149a85cc05e9a722575feb770a217c73fd6145cf0102473044022002eac5d11f3800131985c14a3d1bc03dfe5e694f5731bde39b0d2b183eb7d3d702201d62e7ff2dd433260bf7a8223db400d539a2c4eccd27a5aa24d83f5ad9e9e1750121031ac6d25833a5961e2a8822b2e8b0ac1fd55d90cbbbb18a780552cbd66fc02bb35c099c61".into(); + let ext_tx = BitcoinTransaction::from(tx.clone()); + assert_eq!(tx.hash().reversed().to_string(), ext_tx.txid().to_string()); + } +} diff --git a/mm2src/mm2_bitcoin/chain/src/lib.rs b/mm2src/mm2_bitcoin/chain/src/lib.rs index db7e2dd4b9..6f3422f8df 100644 --- a/mm2src/mm2_bitcoin/chain/src/lib.rs +++ b/mm2src/mm2_bitcoin/chain/src/lib.rs @@ -1,5 +1,5 @@ -#[cfg(not(target_arch = "wasm32"))] -extern crate bitcoin as ext_bitcoin; +#[cfg(feature = "ext-bitcoin")] +extern crate bitcoin; extern crate bitcrypto as crypto; extern crate primitives; extern crate rustc_hex as hex; @@ -14,6 +14,8 @@ mod block_header; mod merkle_root; mod raw_block; pub use raw_block::{RawBlockHeader, RawHeaderError}; +#[cfg(feature = "ext-bitcoin")] +pub mod ext_bitcoin; mod transaction; /// `IndexedBlock` extension diff --git a/mm2src/mm2_bitcoin/chain/src/transaction.rs b/mm2src/mm2_bitcoin/chain/src/transaction.rs index 55dc66eda7..eb72180954 100644 --- a/mm2src/mm2_bitcoin/chain/src/transaction.rs +++ b/mm2src/mm2_bitcoin/chain/src/transaction.rs @@ -4,12 +4,6 @@ use bytes::Bytes; use constants::{LOCKTIME_THRESHOLD, SEQUENCE_FINAL}; use crypto::{dhash256, sha256}; -#[cfg(not(target_arch = "wasm32"))] -use ext_bitcoin::blockdata::transaction::{OutPoint as ExtOutpoint, Transaction as ExtTransaction, TxIn, TxOut}; -#[cfg(not(target_arch = "wasm32"))] -use ext_bitcoin::hash_types::Txid; -#[cfg(not(target_arch = "wasm32"))] -use ext_bitcoin::{PackedLockTime, Sequence, Witness}; use hash::{CipherText, EncCipherText, OutCipherText, ZkProof, ZkProofSapling, H256, H512, H64}; use hex::FromHex; use ser::{deserialize, serialize, serialize_with_flags, SERIALIZE_TRANSACTION_WITNESS}; @@ -44,16 +38,6 @@ impl OutPoint { } } -#[cfg(not(target_arch = "wasm32"))] -impl From for ExtOutpoint { - fn from(outpoint: OutPoint) -> Self { - ExtOutpoint { - txid: Txid::from_hash(outpoint.hash.to_sha256d()), - vout: outpoint.index, - } - } -} - #[derive(Debug, PartialEq, Default, Clone)] pub struct TransactionInput { pub previous_output: OutPoint, @@ -81,18 +65,6 @@ impl TransactionInput { } } -#[cfg(not(target_arch = "wasm32"))] -impl From for TxIn { - fn from(txin: TransactionInput) -> Self { - TxIn { - previous_output: txin.previous_output.into(), - script_sig: txin.script_sig.take().into(), - sequence: Sequence(txin.sequence), - witness: Witness::from_vec(txin.script_witness.into_iter().map(|s| s.take()).collect()), - } - } -} - #[derive(Debug, PartialEq, Clone, Serializable, Deserializable)] pub struct TransactionOutput { pub value: u64, @@ -108,16 +80,6 @@ impl Default for TransactionOutput { } } -#[cfg(not(target_arch = "wasm32"))] -impl From for TxOut { - fn from(txout: TransactionOutput) -> Self { - TxOut { - value: txout.value, - script_pubkey: txout.script_pubkey.take().into(), - } - } -} - #[derive(Debug, PartialEq, Clone, Serializable, Deserializable)] pub struct ShieldedSpend { pub cv: H256, @@ -245,18 +207,6 @@ impl From<&'static str> for Transaction { } } -#[cfg(not(target_arch = "wasm32"))] -impl From for ExtTransaction { - fn from(tx: Transaction) -> Self { - ExtTransaction { - version: tx.version, - lock_time: PackedLockTime(tx.lock_time), - input: tx.inputs.into_iter().map(|i| i.into()).collect(), - output: tx.outputs.into_iter().map(|o| o.into()).collect(), - } - } -} - #[allow(clippy::upper_case_acronyms)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum TxHashAlgo { @@ -646,8 +596,6 @@ impl Deserializable for Transaction { #[cfg(test)] mod tests { - #[cfg(not(target_arch = "wasm32"))] - use super::ExtTransaction; use super::{Bytes, OutPoint, Transaction, TransactionInput, TransactionOutput}; use hash::{H256, H512}; use hex::ToHex; @@ -1131,15 +1079,6 @@ mod tests { assert_eq!(actual, expected); } - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn test_from_tx_to_ext_tx() { - // https://live.blockcypher.com/btc-testnet/tx/2be90e03abb4d5328bf7e9467ca9c571aef575837b55f1253119b87e85ccb94f/ - let tx: Transaction = "010000000001016546e6d844ad0142c8049a839e8deae16c17f0a6587e36e75ff2181ed7020a800100000000ffffffff0247070800000000002200200bbfbd271853ec0a775e5455d4bb19d32818e9b5bda50655ac183fb15c9aa01625910300000000001600149a85cc05e9a722575feb770a217c73fd6145cf0102473044022002eac5d11f3800131985c14a3d1bc03dfe5e694f5731bde39b0d2b183eb7d3d702201d62e7ff2dd433260bf7a8223db400d539a2c4eccd27a5aa24d83f5ad9e9e1750121031ac6d25833a5961e2a8822b2e8b0ac1fd55d90cbbbb18a780552cbd66fc02bb35c099c61".into(); - let ext_tx = ExtTransaction::from(tx.clone()); - assert_eq!(tx.hash().reversed().to_string(), ext_tx.txid().to_string()); - } - #[test] fn n_time_posv_transaction() { let raw = "0200000001fa402b05b9108ec4762247d74c48a2ff303dd832d24c341c486e32cef0434177010000004847304402207a5283cc0fe6fc384744545cb600206ec730d0cdfa6a5e1479cb509fda536ee402202bec1e79b90638f1c608d805b2877fefc8fa6d0df279f58f0a70883e0e0609ce01ffffffff030000000000000000006a734110a10a0000232102fa0ecb032c7cb7be378efd03a84532b5cf1795996bfad854f042dc521616bfdcacd57f643201000000232103c8fc5c87f00bcc32b5ce5c036957f8befeff05bf4d88d2dcde720249f78d9313ac00000000dfcb3c64"; diff --git a/mm2src/mm2_bitcoin/keys/src/lib.rs b/mm2src/mm2_bitcoin/keys/src/lib.rs index 3ce18fcb4e..ac087c480e 100644 --- a/mm2src/mm2_bitcoin/keys/src/lib.rs +++ b/mm2src/mm2_bitcoin/keys/src/lib.rs @@ -1,5 +1,9 @@ //! Bitcoin keys. +// TODO: Remove this allow when Rust 1.92 regression is fixed. +// See: https://github.com/rust-lang/rust/issues/147648 +#![allow(unused_assignments)] + extern crate bech32; extern crate bitcrypto as crypto; extern crate bs58; diff --git a/mm2src/mm2_bitcoin/primitives/src/lib.rs b/mm2src/mm2_bitcoin/primitives/src/lib.rs index d9cc678a1c..19cb627674 100644 --- a/mm2src/mm2_bitcoin/primitives/src/lib.rs +++ b/mm2src/mm2_bitcoin/primitives/src/lib.rs @@ -1,5 +1,4 @@ #![expect(clippy::assign_op_pattern)] -#![expect(clippy::ptr_offset_with_cast)] #![expect(clippy::manual_div_ceil)] extern crate bitcoin_hashes; diff --git a/mm2src/mm2_bitcoin/rpc/Cargo.toml b/mm2src/mm2_bitcoin/rpc/Cargo.toml index ae2fec9dc2..61a7f3de35 100644 --- a/mm2src/mm2_bitcoin/rpc/Cargo.toml +++ b/mm2src/mm2_bitcoin/rpc/Cargo.toml @@ -15,7 +15,7 @@ serde_derive.workspace = true rustc-hex.workspace = true serialization = { path = "../serialization" } -chain = { path = "../chain" } +chain = { path = "../chain", default-features = false } primitives = { path = "../primitives" } keys = { path = "../keys" } script = { path = "../script" } diff --git a/mm2src/mm2_bitcoin/script/Cargo.toml b/mm2src/mm2_bitcoin/script/Cargo.toml index 61cfd06e2b..a7363c0785 100644 --- a/mm2src/mm2_bitcoin/script/Cargo.toml +++ b/mm2src/mm2_bitcoin/script/Cargo.toml @@ -9,7 +9,7 @@ doctest = false [dependencies] bitcrypto = { path = "../crypto" } -chain = { path = "../chain" } +chain = { path = "../chain", default-features = false } keys = { path = "../keys" } primitives = { path = "../primitives" } serde.workspace = true diff --git a/mm2src/mm2_bitcoin/script/src/sign.rs b/mm2src/mm2_bitcoin/script/src/sign.rs index b4a3399a7e..c5eb79d3be 100644 --- a/mm2src/mm2_bitcoin/script/src/sign.rs +++ b/mm2src/mm2_bitcoin/script/src/sign.rs @@ -10,6 +10,7 @@ use hash::{H256, H512}; use keys::KeyPair; use ser::Stream; use serde::Deserialize; +use std::collections::BTreeSet; use std::convert::TryInto; use {Builder, Script}; @@ -29,6 +30,8 @@ pub enum SignatureVersion { WitnessV0, #[serde(rename = "fork_id")] ForkId, + #[serde(rename = "fork_id_rxd")] + ForkIdRxd, } #[derive(Debug, PartialEq, Clone, Copy)] @@ -79,7 +82,7 @@ impl Sighash { pub fn is_defined(version: SignatureVersion, u: u32) -> bool { // reset anyone_can_pay && fork_id (if applicable) bits let u = match version { - SignatureVersion::ForkId => u & !(0x40 | 0x80), + SignatureVersion::ForkId | SignatureVersion::ForkIdRxd => u & !(0x40 | 0x80), _ => u & !(0x80), }; @@ -90,7 +93,7 @@ impl Sighash { /// Creates Sighash from any u, even if is_defined() == false pub fn from_u32(version: SignatureVersion, u: u32) -> Self { let anyone_can_pay = (u & 0x80) == 0x80; - let fork_id = version == SignatureVersion::ForkId && (u & 0x40) == 0x40; + let fork_id = matches!(version, SignatureVersion::ForkId | SignatureVersion::ForkIdRxd) && (u & 0x40) == 0x40; let base = match u & 0x1f { 2 => SighashBase::None, 3 => SighashBase::Single, @@ -246,7 +249,10 @@ impl TransactionInputSigner { SignatureVersion::ForkId if sighash.fork_id => { self.signature_hash_fork_id(input_index, input_amount, script_pubkey, sighashtype, sighash) }, - SignatureVersion::Base | SignatureVersion::ForkId => { + SignatureVersion::ForkIdRxd if sighash.fork_id => { + self.signature_hash_fork_id_rxd(input_index, input_amount, script_pubkey, sighashtype, sighash) + }, + SignatureVersion::Base | SignatureVersion::ForkId | SignatureVersion::ForkIdRxd => { self.signature_hash_original(input_index, script_pubkey, sighashtype, sighash) }, SignatureVersion::WitnessV0 => { @@ -435,6 +441,43 @@ impl TransactionInputSigner { self.signature_hash_witness0(input_index, input_amount, script_pubkey, sighashtype, sighash) } + fn signature_hash_fork_id_rxd( + &self, + input_index: usize, + input_amount: u64, + script_pubkey: &Script, + sighashtype: u32, + sighash: Sighash, + ) -> H256 { + if input_index >= self.inputs.len() { + return 1u8.into(); + } + + if sighash.base == SighashBase::Single && input_index >= self.outputs.len() { + return 1u8.into(); + } + + let hash_prevouts = compute_hash_prevouts(sighash, &self.inputs); + let hash_sequence = compute_hash_sequence(sighash, &self.inputs); + let hash_output_hashes = compute_hash_output_hashes_rxd(sighash, input_index, &self.outputs); + let hash_outputs = compute_hash_outputs(sighash, input_index, &self.outputs); + + let mut stream = Stream::default(); + stream.append(&self.version); + stream.append(&hash_prevouts); + stream.append(&hash_sequence); + stream.append(&self.inputs[input_index].previous_output); + stream.append_list(script_pubkey); + stream.append(&input_amount); + stream.append(&self.inputs[input_index].sequence); + stream.append(&hash_output_hashes); + stream.append(&hash_outputs); + stream.append(&self.lock_time); + stream.append(&sighashtype); + let out = stream.out(); + dhash256(&out) + } + /// https://github.com/zcash/zips/blob/master/zip-0243.rst#notes /// This method doesn't cover all possible Sighash combinations so it doesn't fully match the /// specification, however I don't need other cases yet as BarterDEX marketmaker always uses @@ -615,6 +658,126 @@ fn compute_hash_outputs(sighash: Sighash, input_index: usize, outputs: &[Transac } } +fn compute_hash_output_hashes_rxd(sighash: Sighash, input_index: usize, outputs: &[TransactionOutput]) -> H256 { + match sighash.base { + SighashBase::All => { + let mut stream = Stream::default(); + for output in outputs { + append_output_data_summary_rxd(&mut stream, output); + } + dhash256(&stream.out()) + }, + SighashBase::Single if input_index < outputs.len() => { + let mut stream = Stream::default(); + append_output_data_summary_rxd(&mut stream, &outputs[input_index]); + dhash256(&stream.out()) + }, + SighashBase::Single | SighashBase::None => 0u8.into(), + } +} + +fn append_output_data_summary_rxd(stream: &mut Stream, output: &TransactionOutput) { + let script_pubkey_hash = dhash256(output.script_pubkey.as_ref()); + let sorted_push_refs = sorted_push_refs_rxd(output.script_pubkey.as_ref()); + let total_refs: u32 = sorted_push_refs.len() as u32; + let refs_hash = if sorted_push_refs.is_empty() { + 0u8.into() + } else { + let refs_concat: Vec = sorted_push_refs.into_iter().flatten().collect(); + dhash256(&refs_concat) + }; + + stream.append(&output.value); + stream.append(&script_pubkey_hash); + stream.append(&total_refs); + stream.append(&refs_hash); +} + +fn sorted_push_refs_rxd(script_pubkey: &[u8]) -> Vec<[u8; 36]> { + const OP_PUSHDATA1: u8 = 0x4c; + const OP_PUSHDATA2: u8 = 0x4d; + const OP_PUSHDATA4: u8 = 0x4e; + const OP_PUSHINPUTREF: u8 = 0xd0; + const OP_REQUIREINPUTREF: u8 = 0xd1; + const OP_DISALLOWPUSHINPUTREF: u8 = 0xd2; + const OP_DISALLOWPUSHINPUTREFSIBLING: u8 = 0xd3; + const OP_PUSHINPUTREFSINGLETON: u8 = 0xd8; + + let mut refs = BTreeSet::new(); + let mut pos = 0usize; + + while pos < script_pubkey.len() { + let opcode = script_pubkey[pos]; + pos += 1; + + match opcode { + 0x01..=0x4b => { + let push_len = opcode as usize; + match pos.checked_add(push_len) { + Some(end) if end <= script_pubkey.len() => pos = end, + _ => break, + } + }, + OP_PUSHDATA1 => { + if pos >= script_pubkey.len() { + break; + } + let push_len = script_pubkey[pos] as usize; + pos += 1; + match pos.checked_add(push_len) { + Some(end) if end <= script_pubkey.len() => pos = end, + _ => break, + } + }, + OP_PUSHDATA2 => { + if pos + 2 > script_pubkey.len() { + break; + } + let push_len = u16::from_le_bytes([script_pubkey[pos], script_pubkey[pos + 1]]) as usize; + pos += 2; + match pos.checked_add(push_len) { + Some(end) if end <= script_pubkey.len() => pos = end, + _ => break, + } + }, + OP_PUSHDATA4 => { + if pos + 4 > script_pubkey.len() { + break; + } + let push_len = u32::from_le_bytes([ + script_pubkey[pos], + script_pubkey[pos + 1], + script_pubkey[pos + 2], + script_pubkey[pos + 3], + ]) as usize; + pos += 4; + match pos.checked_add(push_len) { + Some(end) if end <= script_pubkey.len() => pos = end, + _ => break, + } + }, + OP_PUSHINPUTREF + | OP_PUSHINPUTREFSINGLETON + | OP_REQUIREINPUTREF + | OP_DISALLOWPUSHINPUTREF + | OP_DISALLOWPUSHINPUTREFSIBLING => { + if pos + 36 > script_pubkey.len() { + break; + } + if matches!(opcode, OP_PUSHINPUTREF | OP_PUSHINPUTREFSINGLETON) { + let mut reference = [0u8; 36]; + reference.copy_from_slice(&script_pubkey[pos..pos + 36]); + refs.insert(reference); + } + pos += 36; + }, + _ => (), + } + } + + refs.into_iter().collect() +} + fn blake_2b_256_personal(input: &[u8], personal: &[u8]) -> Result { let bytes: [u8; 32] = Blake2b::new() .hash_length(32) @@ -631,14 +794,15 @@ fn blake_2b_256_personal(input: &[u8], personal: &[u8]) -> Result #[cfg(test)] mod tests { use super::{ - blake_2b_256_personal, Sighash, SighashBase, SignatureVersion, TransactionInputSigner, UnsignedTransactionInput, + blake_2b_256_personal, sorted_push_refs_rxd, Sighash, SighashBase, SignatureVersion, TransactionInputSigner, + UnsignedTransactionInput, }; use bytes::Bytes; use chain::{OutPoint, Transaction, TransactionOutput}; use hash::{H160, H256}; use keys::{ prefixes::{BTC_PREFIXES, T_BTC_PREFIXES}, - Address, AddressHashEnum, Private, + Address, AddressHashEnum, Private, Public, Signature, }; use script::Script; use ser::deserialize; @@ -792,6 +956,77 @@ mod tests { assert!(Sighash::is_defined(SignatureVersion::ForkId, 0x00000081)); assert!(Sighash::is_defined(SignatureVersion::ForkId, 0x000000C2)); assert!(Sighash::is_defined(SignatureVersion::ForkId, 0x00000043)); + + assert!(Sighash::is_defined(SignatureVersion::ForkIdRxd, 0x00000081)); + assert!(Sighash::is_defined(SignatureVersion::ForkIdRxd, 0x000000C2)); + assert!(Sighash::is_defined(SignatureVersion::ForkIdRxd, 0x00000043)); + } + + #[test] + fn test_rxd_forkid_sighash_vector() { + const SPEND_TX_HEX: &str = "0100000002502d0525588143dee5d3857b6b901805fb4ec53cca16b374f25bf8c3e12644ee010000006a47304402204715bf639b322d08cfabb2b1b7af1ba8b6b3d571a3629a2ae5faa39b8483942f0220424747e9e1cda6e034501171292fac690da050d5221cbfd318d880eb9701405541210275c802fa50d9a1f2b89c7e43b74c77e8826209b8aa79ed144c6768b9a6f262a1ffffffff457a0a555e6fbdaf5e5f2c241847026e1cf38ee3f523ebcd0dc27683fdcd4c55000000006a47304402200be359814746c94556d80fae90f02cc9bcff50a60a1a87dadce7579eb986d783022028f7d7a39561fbacde025a91b41b6279d4ffda991d9ef69804bde990f663c1df41210275c802fa50d9a1f2b89c7e43b74c77e8826209b8aa79ed144c6768b9a6f262a1ffffffff0300ca9a3b0000000017a914cf53278b47afd12ffd864a00090b6df471d22a16870000000000000000166a14bddadc147d635060f518c7d59e481090dc066f8bdb1ae329010000001976a914d4466bdfcc471b6207a108289b847d562044539288acd8c89169"; + const INPUT_INDEX: usize = 0; + const INPUT_AMOUNT_SATS: u64 = 998_119_699; + const PREVOUT_SCRIPT_CODE_HEX: &str = "76a914d4466bdfcc471b6207a108289b847d562044539288ac"; + const SIGHASH_U32: u32 = 0x41; + const DER_SIGNATURE_HEX: &str = + "304402204715bf639b322d08cfabb2b1b7af1ba8b6b3d571a3629a2ae5faa39b8483942f0220424747e9e1cda6e034501171292fac690da050d5221cbfd318d880eb97014055"; + const PUBKEY_HEX: &str = "0275c802fa50d9a1f2b89c7e43b74c77e8826209b8aa79ed144c6768b9a6f262a1"; + const EXPECTED_SIGHASH_HEX: &str = "201a2654d0ed04643080bbdda4acdf40a3d31a6a2e0c486270476fd3c6409187"; + + let tx: Transaction = SPEND_TX_HEX.into(); + let mut signer: TransactionInputSigner = tx.into(); + signer.inputs[INPUT_INDEX].amount = INPUT_AMOUNT_SATS; + + let script_code: Script = PREVOUT_SCRIPT_CODE_HEX.into(); + let sighash = signer.signature_hash( + INPUT_INDEX, + INPUT_AMOUNT_SATS, + &script_code, + SignatureVersion::ForkIdRxd, + SIGHASH_U32, + ); + + let signature: Signature = DER_SIGNATURE_HEX.into(); + let pubkey_bytes: Bytes = PUBKEY_HEX.into(); + let pubkey = Public::from_slice(&pubkey_bytes).expect("valid compressed pubkey"); + + assert!(pubkey + .verify(&sighash, &signature) + .expect("signature verification should not error")); + + let expected_sighash: H256 = EXPECTED_SIGHASH_HEX.into(); + assert_eq!( + sighash, expected_sighash, + "RXD sighash mismatch: computed {}, expected {}", + sighash, expected_sighash + ); + } + + #[test] + fn test_sorted_push_refs_rxd_extracts_and_sorts_push_refs() { + const OP_PUSHINPUTREF: u8 = 0xd0; + const OP_REQUIREINPUTREF: u8 = 0xd1; + const OP_PUSHINPUTREFSINGLETON: u8 = 0xd8; + + let ref_a = [0x11u8; 36]; + let ref_b = [0x22u8; 36]; + let ref_ignored = [0x33u8; 36]; + + let mut script_pubkey = Vec::new(); + script_pubkey.extend_from_slice(&[0x51]); + script_pubkey.push(OP_PUSHINPUTREF); + script_pubkey.extend_from_slice(&ref_b); + script_pubkey.push(OP_REQUIREINPUTREF); + script_pubkey.extend_from_slice(&ref_ignored); + script_pubkey.push(OP_PUSHINPUTREFSINGLETON); + script_pubkey.extend_from_slice(&ref_a); + script_pubkey.push(OP_PUSHINPUTREF); + script_pubkey.extend_from_slice(&ref_a); + + let refs = sorted_push_refs_rxd(&script_pubkey); + + assert_eq!(refs, vec![ref_a, ref_b]); } #[test] diff --git a/mm2src/mm2_bitcoin/spv_validation/Cargo.toml b/mm2src/mm2_bitcoin/spv_validation/Cargo.toml index 1c3bd46ef7..889d98290e 100644 --- a/mm2src/mm2_bitcoin/spv_validation/Cargo.toml +++ b/mm2src/mm2_bitcoin/spv_validation/Cargo.toml @@ -9,7 +9,7 @@ doctest = false [dependencies] async-trait.workspace = true -chain = {path = "../chain"} +chain = { path = "../chain", default-features = false } derive_more.workspace = true keys = {path = "../keys"} primitives = { path = "../primitives" } diff --git a/mm2src/mm2_db/src/indexed_db/drivers/cursor/cursor.rs b/mm2src/mm2_db/src/indexed_db/drivers/cursor/cursor.rs index 2faaa7b9d1..f5e45240bb 100644 --- a/mm2src/mm2_db/src/indexed_db/drivers/cursor/cursor.rs +++ b/mm2src/mm2_db/src/indexed_db/drivers/cursor/cursor.rs @@ -421,6 +421,22 @@ impl CursorDriver { } } +impl Drop for CursorDriver { + fn drop(&mut self) { + // Detach JS -> Rust callbacks to prevent "closure invoked after being dropped" error. + // + // When Rust passes a closure to JS via set_onsuccess/set_onerror, JS holds a reference + // to call it when events fire. If this Rust struct is dropped (freeing the closure memory) + // while JS still has a pending cursor operation, the callback would fire into freed memory, + // causing wasm-bindgen to panic with "closure invoked after being dropped". + // + // Setting callbacks to None tells JS "there's no handler anymore" - when the event fires, + // JS sees null and does nothing safely. + self.cursor_request.set_onsuccess(None); + self.cursor_request.set_onerror(None); + } +} + pub(crate) enum IdbCursorEnum { Empty(IdbEmptyCursor), SingleKey(IdbSingleKeyCursor), diff --git a/mm2src/mm2_db/src/indexed_db/drivers/transaction.rs b/mm2src/mm2_db/src/indexed_db/drivers/transaction.rs index bb1ca9f45f..93bfae2f62 100644 --- a/mm2src/mm2_db/src/indexed_db/drivers/transaction.rs +++ b/mm2src/mm2_db/src/indexed_db/drivers/transaction.rs @@ -107,3 +107,19 @@ impl IdbTransactionImpl { } } } + +impl Drop for IdbTransactionImpl { + fn drop(&mut self) { + // Detach JS -> Rust callback to prevent "closure invoked after being dropped" error. + // + // When Rust passes a closure to JS via set_onabort, JS holds a reference to call it + // when abort events fire. Transactions can abort for various reasons (store errors, + // explicit abort, timeout). If this Rust wrapper is dropped while the transaction is + // still open in JS, an abort event could fire into freed memory, causing wasm-bindgen + // to panic with "closure invoked after being dropped". + // + // Setting the callback to None tells JS "there's no handler anymore" - when the event + // fires, JS sees null and does nothing safely. + self.transaction.set_onabort(None); + } +} diff --git a/mm2src/mm2_db/src/lib.rs b/mm2src/mm2_db/src/lib.rs index 0a69fa1484..27f5c59aae 100644 --- a/mm2src/mm2_db/src/lib.rs +++ b/mm2src/mm2_db/src/lib.rs @@ -1,3 +1,7 @@ +// TODO: Remove this allow when Rust 1.92 regression is fixed. +// See: https://github.com/rust-lang/rust/issues/147648 +#![allow(unused_assignments)] + #[cfg(target_arch = "wasm32")] #[path = "indexed_db/indexed_db.rs"] pub mod indexed_db; diff --git a/mm2src/mm2_main/AGENTS.md b/mm2src/mm2_main/AGENTS.md new file mode 100644 index 0000000000..48349943f5 --- /dev/null +++ b/mm2src/mm2_main/AGENTS.md @@ -0,0 +1,254 @@ +# mm2_main β€” RPC, Swaps, and Application Logic + +> **Note:** Always follow the root `/CLAUDE.md` for global conventions (fmt, clippy, error handling, etc.). + +Core application crate: RPC dispatcher, atomic swap engines, order matching, streaming. + +## Responsibilities + +- Application entry point and lifecycle (`lp_native_dex.rs`) +- RPC request routing and handler registration +- Atomic swap state machines (V1 legacy, V2 TPU) +- Order matching and orderbook management +- SSE event streaming +- Swap watcher coordination +- WalletConnect session management + +## Module Structure + +``` +src/ +β”œβ”€β”€ mm2.rs # Library entry point +β”œβ”€β”€ lp_native_dex.rs # Application lifecycle (lp_main, lp_run) +β”œβ”€β”€ rpc/ +β”‚ β”œβ”€β”€ dispatcher/ # RPC routing +β”‚ β”‚ β”œβ”€β”€ dispatcher.rs # Main v2 dispatcher +β”‚ β”‚ └── dispatcher_legacy.rs +β”‚ β”œβ”€β”€ lp_commands/ # Handler implementations +β”‚ β”‚ β”œβ”€β”€ pubkey.rs, tokens.rs, trezor.rs, db_id.rs, legacy.rs +β”‚ β”‚ β”œβ”€β”€ one_inch.rs, one_inch/ # 1inch integration +β”‚ β”‚ └── lr_swap.rs, lr_swap/ # Liquidity routing +β”‚ β”œβ”€β”€ streaming_activations/ # SSE handlers +β”‚ β”‚ β”œβ”€β”€ balance.rs, orderbook.rs, swaps.rs, orders.rs +β”‚ β”‚ β”œβ”€β”€ heartbeat.rs, network.rs, shutdown_signal.rs +β”‚ β”‚ β”œβ”€β”€ tx_history.rs, fee_estimation.rs, disable.rs +β”‚ β”‚ └── mod.rs +β”‚ β”œβ”€β”€ wc_commands/ # WalletConnect RPCs +β”‚ └── rate_limiter.rs # Request rate limiting +β”œβ”€β”€ lp_swap/ +β”‚ β”œβ”€β”€ maker_swap.rs # V1 maker state machine +β”‚ β”œβ”€β”€ taker_swap.rs # V1 taker state machine +β”‚ β”œβ”€β”€ maker_swap_v2.rs # V2 maker (TPU protocol) +β”‚ β”œβ”€β”€ taker_swap_v2.rs # V2 taker (TPU protocol) +β”‚ β”œβ”€β”€ swap_watcher.rs # Watcher node logic +β”‚ β”œβ”€β”€ swap_v2_rpcs.rs # V2 RPC handlers +β”‚ β”œβ”€β”€ trade_preimage.rs # Fee estimation +β”‚ β”œβ”€β”€ swap_lock.rs, swap_events.rs, saved_swap.rs +β”‚ └── pubkey_banning.rs, check_balance.rs +β”œβ”€β”€ lp_ordermatch/ # Order matching engine +β”‚ β”œβ”€β”€ best_orders.rs # Best order selection +β”‚ β”œβ”€β”€ orderbook_rpc.rs # Orderbook RPCs +β”‚ β”œβ”€β”€ simple_market_maker.rs # Market maker bot +β”‚ └── order_events.rs, orderbook_events.rs +β”œβ”€β”€ lp_wallet/ # Wallet management, mnemonic storage +β”œβ”€β”€ lp_init/ # Hardware wallet init (Trezor, MetaMask) +β”‚ β”œβ”€β”€ init_hw.rs, init_metamask.rs, init_context.rs +β”œβ”€β”€ lp_network.rs # P2P message handling +β”œβ”€β”€ lp_healthcheck.rs # Peer health checking +β”œβ”€β”€ lp_stats.rs # Version statistics +└── database/ # Swap/order persistence +``` + +## RPC Patterns + +### Adding a Handler + +1. Create handler in `rpc/lp_commands/.rs`: +```rust +pub async fn my_handler(ctx: MmArc, req: MyRequest) -> MmResult { + let coin = lp_coinfind_or_err(&ctx, &req.coin).await?; + // Business logic here + Ok(MyResponse { ... }) +} +``` + +2. Register in `dispatcher/dispatcher.rs`: +```rust +// In dispatcher_v2() match: +"my_method" => handle_mmrpc(ctx, request, my_handler).await, +``` + +### Namespace Routing + +| Prefix | Router | Use Case | +|--------|--------|----------| +| (none) | `dispatcher_v2` | Stable APIs | +| `task::` | `rpc_task_dispatcher` | Long-running ops | +| `stream::` | `rpc_streaming_dispatcher` | SSE subscriptions | +| `gui_storage::` | `gui_storage_dispatcher` | GUI state (planned for removal) | +| `experimental::` | `experimental_rpcs_dispatcher` | Unstable APIs | +| `lightning::` | `lightning_dispatcher` | Lightning Network (native only) | + +### Task RPC Pattern + +Long-running operations follow four-verb pattern: +```rust +"task::withdraw::init" // Start, returns task_id +"task::withdraw::status" // Poll completion +"task::withdraw::user_action" // Handle prompts (PIN, confirm) +"task::withdraw::cancel" // Abort +``` + +Use `RpcTaskManager` for task lifecycle management. + +## Swap Engines + +### V1 (Legacy) +- `MakerSwap` / `TakerSwap` structs +- Message-driven with timeout-based state transitions +- Coin implements `SwapOps` trait +- Use for: critical fixes only + +### V2 (TPU Protocol) +- `MakerSwapStateMachine` / `TakerSwapStateMachine` +- Deterministic phases via `mm2_state_machine` +- Persistence and reentrancy support +- Watcher rewards integration +- Both coins implement `MakerCoinSwapOpsV2` + `TakerCoinSwapOpsV2` +- Use for: all new features + +### Key Invariants + +- **Atomicity**: Swaps complete fully or refund entirely (HTLC guarantee) +- **Timelocks**: Maker locktime > taker locktime (prevents race conditions) +- **Secret flow**: Maker generates secret β†’ Taker reveals via spend +- **State persistence**: Swaps survive restarts via DB checkpointing + +## Swap Watcher + +Third-party nodes assisting swap completion when participants offline. + +- P2P topic: `"swpwtchr"` +- Can spend maker payment or refund taker payment +- Log markers: `MAKER_PAYMENT_SPEND_SENT_LOG`, `TAKER_PAYMENT_REFUND_SENT_LOG` +- Locking prevents duplicate processing (`SwapWatcherLock`) + +## Streaming (SSE) + +Enable via `stream::::enable`, disable via `stream::disable`: +- `balance::enable` β€” Account balance updates +- `swap_status::enable` β€” Swap state changes +- `orderbook::enable` β€” Orderbook changes +- `order_status::enable` β€” Order state changes +- `heartbeat::enable` β€” Keep-alive +- `network::enable` β€” Network connectivity +- `tx_history::enable` β€” Transaction history updates +- `fee_estimator::enable` β€” Gas fee updates +- `shutdown_signal::enable` β€” Shutdown notifications (native, non-Windows only) + +## Interactions + +| Crate | Usage | +|-------|-------| +| **coins** | `lp_coinfind_or_err`, coin traits for swap operations | +| **crypto** | Key derivation for swap secrets | +| **mm2_core** | `MmArc` context access | +| **mm2_p2p** | Gossipsub for swap/order messages | +| **coins_activation** | Coin initialization | +| **mm2_event_stream** | StreamingManager for SSE | +| **rpc_task** | RpcTaskManager for long-running operations | +| **kdf_walletconnect** | WalletConnect session management | +| **mm2_number** | MmNumber for amounts | +| **mm2_net** | HTTP transport (native) | +| **mm2_gui_storage** | GUI state persistence (planned for removal, unused) | +| **trading_api** | 1inch integration | + +## Common Pitfalls + +| Issue | Solution | +|-------|----------| +| Handler not found | Register in correct dispatcher match arm | +| Task never completes | Check `RpcTaskManager` status handling | +| Swap stuck | Verify timelock calculations and coin connectivity | +| Stream not receiving | Confirm subscription via `stream::*::enable` | + +## Tests + +- Unit tests: `cargo test -p mm2_main --lib` +- Integration: `cargo test --test mm2_tests_main` +- Docker swaps: `cargo test --test docker_tests_main --features run-docker-tests` +- TRON tests: `cargo test --test mm2_tests_main --features tron-network-tests tron_` + +### Docker Test Infrastructure + +Docker tests run against local blockchain test nodes to verify atomic swap functionality. Tests are split into feature-gated modules for parallel CI execution. + +#### Test Module Structure + +``` +tests/docker_tests/ +β”œβ”€β”€ helpers/ +β”‚ β”œβ”€β”€ docker_ops.rs # CoinDockerOps trait (shared by utxo, zcoin) +β”‚ β”œβ”€β”€ env.rs # MM_CTX, service constants, DockerNode +β”‚ β”œβ”€β”€ eth.rs # Geth/ERC20 helpers (contracts, funding) +β”‚ β”œβ”€β”€ mod.rs # Module index +β”‚ β”œβ”€β”€ qrc20.rs # Qtum/QRC20 helpers +β”‚ β”œβ”€β”€ sia.rs # Sia helpers +β”‚ β”œβ”€β”€ swap.rs # Cross-chain swap orchestration (trade_base_rel) +β”‚ β”œβ”€β”€ tendermint.rs # Tendermint/Cosmos/IBC helpers +β”‚ β”œβ”€β”€ utxo.rs # UTXO coin helpers (MYCOIN, BCH/SLP) +β”‚ └── zcoin.rs # ZCoin/Zombie helpers +β”œβ”€β”€ swap_watcher_tests/ +β”‚ β”œβ”€β”€ eth.rs # ETH watcher tests (disabled by default) +β”‚ β”œβ”€β”€ mod.rs # Watcher test helpers +β”‚ └── utxo.rs # UTXO watcher tests (stable) +β”œβ”€β”€ docker_ordermatch_tests.rs # Cross-chain ordermatching +β”œβ”€β”€ docker_tests_inner.rs # Mixed ETH/UTXO integration +β”œβ”€β”€ eth_docker_tests.rs # ETH/ERC20/NFT coin & swap v2 tests +β”œβ”€β”€ eth_inner_tests.rs # ETH-only ordermatching/wallet tests +β”œβ”€β”€ qrc20_tests.rs # Qtum/QRC20 tests +β”œβ”€β”€ runner.rs # Container startup/initialization +β”œβ”€β”€ sia_docker_tests.rs # Sia tests +β”œβ”€β”€ slp_tests.rs # SLP/BCH tests +β”œβ”€β”€ swap_proto_v2_tests.rs # UTXO swap protocol v2 +β”œβ”€β”€ swap_tests.rs # Cross-chain SLP swaps +β”œβ”€β”€ swaps_confs_settings_sync_tests.rs +β”œβ”€β”€ swaps_file_lock_tests.rs +β”œβ”€β”€ tendermint_swap_tests.rs # Tendermint cross-chain swaps +β”œβ”€β”€ tendermint_tests.rs # Cosmos/IBC tests +β”œβ”€β”€ utxo_ordermatch_v1_tests.rs # UTXO-only ordermatching +β”œβ”€β”€ utxo_swaps_v1_tests.rs # UTXO swap protocol v1 +└── z_coin_docker_tests.rs # ZCoin/Zombie tests +``` + +#### Feature Flags + +| Feature | Purpose | Containers | +|---------|---------|------------| +| `docker-tests-eth` | ETH/ERC20/NFT tests | Geth | +| `docker-tests-slp` | BCH/SLP token tests | FORSLP | +| `docker-tests-sia` | Sia tests + DSIA swaps | Sia + UTXO | +| `docker-tests-ordermatch` | Orderbook/matching tests | UTXO + Geth | +| `docker-tests-swaps` | Swap protocol tests | UTXO | +| `docker-tests-watchers` | UTXO watcher tests | UTXO | +| `docker-tests-watchers-eth` | ETH watcher tests (unstable) | UTXO + Geth | +| `docker-tests-qrc20` | Qtum/QRC20 tests | Qtum + UTXO | +| `docker-tests-tendermint` | Cosmos/IBC tests | Cosmos | +| `docker-tests-zcoin` | ZCoin/Zombie tests | Zombie | +| `docker-tests-integration` | Cross-chain swaps | ALL | +| `docker-tests-all` | All suites (local dev) | ALL | + +#### Running Tests + +```bash +# Single suite +cargo test --test docker_tests_main --features docker-tests-eth + +# All suites (local development) +cargo test --test docker_tests_main --features docker-tests-all + +# With docker-compose (faster iteration) +KDF_DOCKER_COMPOSE_ENV=1 cargo test --test docker_tests_main --features docker-tests-eth +``` + +See [`docs/DOCKER_TESTS.md`](../../../docs/DOCKER_TESTS.md) for full setup and troubleshooting. diff --git a/mm2src/mm2_main/Cargo.toml b/mm2src/mm2_main/Cargo.toml index b3b7698882..1436c732ae 100644 --- a/mm2src/mm2_main/Cargo.toml +++ b/mm2src/mm2_main/Cargo.toml @@ -17,12 +17,48 @@ custom-swap-locktime = [] # only for testing purposes, should never be activated native = [] # Deprecated track-ctx-pointer = ["common/track-ctx-pointer"] zhtlc-native-tests = ["coins/zhtlc-native-tests"] +tron-network-tests = ["coins/tron-network-tests"] run-docker-tests = ["coins/run-docker-tests"] +# Split docker test features - organized by test category +# +# Chain-specific tests: Test coin implementations for specific blockchain protocols +# (consideration: move to coins::*/tests; far future: separate crates per chain) +docker-tests-slp = ["run-docker-tests"] # BCH-SLP coin tests +docker-tests-sia = ["run-docker-tests"] # Sia coin tests +docker-tests-eth = ["run-docker-tests"] # ETH/ERC20 coin tests +docker-tests-qrc20 = ["run-docker-tests"] # QRC20 coin tests +docker-tests-tendermint = ["run-docker-tests"] # Tendermint/IBC coin tests +docker-tests-zcoin = ["run-docker-tests"] # ZCoin/Zombie coin tests +# +# Protocol tests: Test cross-cutting protocol functionality (swaps, ordermatching, watchers). +# Protocol logic is coin-agnostic; these tests use UTXO coins to verify protocol correctness in isolation. +docker-tests-swaps = ["run-docker-tests"] # Swap protocol tests (v1, v2, confs, file-locks) +docker-tests-ordermatch = ["run-docker-tests"] # Orderbook and matching tests +docker-tests-watchers = ["run-docker-tests"] # Watcher node tests (UTXO-only, stable) +docker-tests-watchers-eth = ["docker-tests-watchers", "coins/enable-eth-watchers"] # Watcher tests (ETH, unstable) +# +# Cross-chain integration tests: Test interactions between different chain families +docker-tests-integration = ["run-docker-tests"] +# Aggregate feature for local development - runs all docker test suites +# Not recommended for CI (use split jobs instead for parallelism) +docker-tests-all = [ + "docker-tests-eth", + "docker-tests-slp", + "docker-tests-sia", + "docker-tests-ordermatch", + "docker-tests-swaps", + "docker-tests-watchers", + "docker-tests-qrc20", + "docker-tests-tendermint", + "docker-tests-zcoin", + "docker-tests-integration", +] +# utxo-walletconnect excluded: it requires the `bitcoin` crate which pulls secp256k1-sys v0.6.1, +# conflicting with v0.4.2 from existing deps and causing duplicate WASM symbol linker errors. default = [] +utxo-walletconnect = ["coins/utxo-walletconnect", "coins_activation/utxo-walletconnect"] trezor-udp = ["crypto/trezor-udp"] # use for tests to connect to trezor emulator over udp run-device-tests = [] -sepolia-maker-swap-v2-tests = [] -sepolia-taker-swap-v2-tests = [] test-ext-api = ["trading_api/test-ext-api"] new-db-arch = ["mm2_core/new-db-arch"] # A temporary feature to integrate the new db architecture incrementally # Temporary feature for implementing IBC wrap/unwrap mechanism and will be removed @@ -37,11 +73,11 @@ bitcrypto = { path = "../mm2_bitcoin/crypto" } blake2.workspace = true bytes.workspace = true cfg-if.workspace = true -chain = { path = "../mm2_bitcoin/chain" } +chain = { path = "../mm2_bitcoin/chain", default-features = false } chrono.workspace = true clap.workspace = true -coins = { path = "../coins" } -coins_activation = { path = "../coins_activation" } +coins = { path = "../coins", default-features = false } +coins_activation = { path = "../coins_activation", default-features = false } common = { path = "../common" } compatible-time.workspace = true crc32fast.workspace = true @@ -107,8 +143,8 @@ trie-root.workspace = true uuid.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] -# TODO: Removing this causes `wasm-pack` to fail when starting a web session (even though we don't use this crate). -# Investigate why. +# Forces `wasm-bindgen` feature on transitive `instant` dep for WASM builds. +# See mm2src/coins/Cargo.toml for detailed explanation. instant = { version = "0.1.12", features = ["wasm-bindgen"] } js-sys.workspace = true mm2_db = { path = "../mm2_db" } @@ -136,8 +172,8 @@ signal-hook-tokio = { version = "0.3", features = [ "futures-v0_3" ] } [dev-dependencies] base64.workspace = true -coins = { path = "../coins", features = ["for-tests"] } -coins_activation = { path = "../coins_activation", features = ["for-tests"] } +coins = { path = "../coins", default-features = false, features = ["for-tests"] } +coins_activation = { path = "../coins_activation", default-features = false, features = ["for-tests"] } common = { path = "../common", features = ["for-tests"] } mm2_test_helpers = { path = "../mm2_test_helpers" } trading_api = { path = "../trading_api", features = ["for-tests"] } diff --git a/mm2src/mm2_main/README.md b/mm2src/mm2_main/README.md deleted file mode 100644 index 10c88b5926..0000000000 --- a/mm2src/mm2_main/README.md +++ /dev/null @@ -1,103 +0,0 @@ -# Market Maker 2 - -This document will help us track the information related to the MarketMaker Rust rewrite. - -## Rewrite goals - -Rewrites and ports -[are costly](http://nibblestew.blogspot.com/2017/04/why-dont-you-just-rewrite-it-in-x.html). -We tend to think that porting is simple, and on some level this intuition is true -because we can skip some high-level design decisions and focus on translating the existing logic, -but it still takes a lot of time -and though we don't need to make some of the new design decisions, -we might spend no less effort to reverse-engineer and understand the old ones. - -So why the rewrite then? - -Carol, in her talk about rewrites, offers some possible reasons: - -"*If* you have some code in C or another language, and need to change it, or it’s slow, or it crashes a -lot, or no one understands it anymore, THEN maybe a rewrite in Rust would be a good fit. -I would also posit that more people are *able* to write production Rust than production C, so if your -team *is* willing to learn Rust, it might actually expand the number of -maintainers." - https://github.com/carols10cents/rust-out-your-c-talk, https://www.youtube.com/watch?v=SKGVItFlK3w. - -And we have some of these: - -* We *need to change* the MarketMaker: -A faster parallel API. -A more transparent operation, telling user what's going on and what went wrong. -Solve more things automatically, not requiring advanced fine-tuning from users, [a better UTXO (splitting/merging) algorithm in particular](https://github.com/artemii235/SuperNET/issues/157). -Ways to more easily deploy it at home, behind NAT and on small computers (like on a spare mobile phone or on a Raspberry Pi 3). -Running not only Alice but also Bob with GUI. -Ability to embed the MarketMaker in the GUI applications. - -* The MarketMaker *crashes a lot*, -to quote hyperDEX: "The biggest issue with the MM right now, is bobs crash or does not have the orders in users orderbook, or when users try to do a order it doesnt work or goes unmatched or other random stuff" -and lukechilds: "We've frequently experienced crashes while querying all swaps with swapstatus". -We want it to be stable and reliable instead. - -## Purely functional core - -One of our goals is to make the MarketMaker 2 more -[stable and reliable](https://softwareengineering.stackexchange.com/questions/158054/stability-vs-reliability). -We want it to crash less often. -If there was a failure, we want to simplify and preferably automate recovery. -And we want to reduce the time between a failure and a fix. - -We'll make a big step towards these goals if the core of the MarketMaker is purely functional. -That is, if we can untangle the *state* of the MarketMaker from the code operating on that state. - -The benefits we want to reap from this are: -* Transparency. Some bugs are hard to fix because we don't have enough information about them. We might be lucky to have been running the program in a debugger or finding the necessary bits it verbose logs, but more often than not this is not the case: we know that a failure has happened, but have no idea where and on what input. Separating the state from the code allows the state to be easily shared with a developer, which means much faster roundtrips between discovering a failure and fixing it. -* Replayability. Having a separate state allows us to easily replay any operation. If a failure occured in the middle of a transaction, it should be possible to try a new version of the code without manually repeating all the steps that were necessary to initiate the transaction. And the updated code will run exactly on the failing transaction, not on some other transaction initiated at a later time, which means that users will benefit from less friction and developers will have a better chance to fix the hard-to-reproduce bugs. -* Testability. Stateless code is much easier to test and according to Kent Beck is often a natural result of a Test-Driven development process. -* Portability. Separating the state from the code allows us to more easily use the stateless parts from the sandboxed environments, such as when running under the Web Assembly (WASM). We only need to port the state-managing layer, fully reusing the stateless code. -* Hot reloading. When the code is separated from state, it's trivial to reload it, both with the shared libraries in CPU-native environments (dlopen) and with WASM in GUI environments. This might positively affect the development cycle, reducing the round-trip time from a failure to a fix. -* Concurrency. MarketMaker can currently only perform a single API operation at the time. The more stateless code we have the easier it should be to introduce the parallel execution of API requests in the future. - -Implementation might consist of two layers. -A layer that is ported to the host environment (native, JS, Java, Objective-C, cross-compiled Raspberry Pi 3, etc) and implements the TCP/IP communication, state management, hot reloading, all things that won't fit into the WASM sandbox. -And a layer that implements the core logic in a stateless manner and which is compiled into a native shared library or, in the future, to WASM. - -Parts of the state might be marked as sensitive. -This will give the users an option to share only the information that can be freely shared, -without a risk of loosing money that is. -Even without the sensitive information a state snapshot might provide the developer with enough data to quickly triage and/or fix the failure, therefore improving the roundtrip time before a failure and a fix. -Plus users will more easily share their problems when it's quick, automatic and doesn't pose a monetary risk. - -The feasibility of this approach is yet to be evaluated, but we can move gradually towards it -by separating the code into the stateful and stateless layers while working on the basic Rust port. - -During the initial Rust port we're going to -a) Mark the ported functions as purely functional or stateful, allowing us to more easily focus on the state management code in the future. -b) Where possible, take a low-hanging fruit and try to refactor the functions towards being stateless. -c) Probably mark as stateful the `sleep`ing functions, because `sleep` can be seen as changing the global state (moving the time forwards) and might negatively affect Transparency (we have no idea what's going on while a function is sleeping), Testability (sleeping tests might kill the TDD development cycle), Portability (sleeps are not compatible with WASM), Hot reloading and Concurrency (let's say we want to load new version of the code, but the old version is still sleeping somewhere). - -## Gradual rewrite - -Above in the [Rewrite goals](#rewrite-goals) section we have identified some of the goals that we pursue with this rewrite. -These goals constitute the Value (in the Lean Production terms) that we are going to create. - -For a project to succeed it is usually important to make shorter the path the Value takes to the users. -(Inventory is waste. If we have created the Value but the users can't get their hands on it, we're wasting that Value). - -Hence we're going to start with a gradual rewrite. Keeping the version under rewrite immediately avaliable to the users willing to experiment with it. - -Let's list the good things that should come out of the gradual rewrite: -* Transparency. With the second version of the MarketMaker being immediately available we can always check the Value we're getting. Is it more stable? Does it have new functions? Or did we hit the wall? What's going on with the code and how can we help? Gradual rewrite is critical for transparency because the change is available in small increments. We can more easily see what function has changed or what new functionality was added when we aren't being uprooted from the familiar context. -* Risk reduction. It comes with transparency, as we can now more easily measure the progress, identify the roadblocks as they occur, see certain problems when they occur and not months after. Plus a gradual rewrite will by default follow the outline of the original project. We have a working system and we're improving it piece by piece, having the original design to fall back to. This makes it less likely for the rewrite to suffer from far-reaching redesign flaws (cf. [Second-system effect](https://en.wikipedia.org/wiki/Second-system_effect)) and creative blocks (cf. [Pantsing](https://www.wikiwrimo.org/wiki/Pantsing)). -* Feedback. Incorporating user feedback is critical for most projects out there, allowing us to iteratively improve the functionality in the right direction (cf. [Fail faster](https://www.youtube.com/watch?v=rDjrOaoHz9s), [PDIA](https://www.youtube.com/watch?v=ZKdjBbiGjao)). The more early we get the feedback, the more time we have to react, and at a lesser cost. -* Motivation. Feedback is not only important to guide us, but also to show us that our work is meaningful and changes lives to the better. It is the cornerstone of Agile (["Build projects around motivated individuals"](https://www.agilealliance.org/agile101/12-principles-behind-the-agile-manifesto/)) and affects our performance on all levels, down to the physical health. - -The plan so far is to by default use the C functions as the atomic units of rewrite. -Rust FFI allows us to swap any C function with a similar Rust function. -Porting on this level we -* reuse the function-level modularity of the C language; -* preserve the code meta-data (Git history will show a nice diff between the C and Rust functions, we'll be able to easily investigate the evolution of the code back to its roots); -* avoid the complexity and slow-downs associated with adding RPC/networking layers or drawing new lines of abstraction; -* have a good indicator of the porting progress (how many functions were ported, how many remains). - -Focusing on the function call chains that are a common part of a failure/crash or touch on the new functionality -will allow us to leverage the [Pareto principle](https://en.wikipedia.org/wiki/Pareto_principle), -advancing on 80% of desired Value (stability, functionality) with 20% of initial effort. \ No newline at end of file diff --git a/mm2src/mm2_main/src/lp_ordermatch.rs b/mm2src/mm2_main/src/lp_ordermatch.rs index 7ecb12654b..42d6d713c0 100644 --- a/mm2src/mm2_main/src/lp_ordermatch.rs +++ b/mm2src/mm2_main/src/lp_ordermatch.rs @@ -6672,8 +6672,10 @@ fn orderbook_address( .map(OrderbookAddress::Transparent) .map_to_mm(OrderbookAddrErr::AddrFromPubkeyError) }, - // Todo: implement TRX address generation - CoinProtocol::TRX { .. } => MmError::err(OrderbookAddrErr::CoinIsNotSupported(coin.to_owned())), + // Todo: implement TRX/TRC20 address generation + CoinProtocol::TRX { .. } | CoinProtocol::TRC20 { .. } => { + MmError::err(OrderbookAddrErr::CoinIsNotSupported(coin.to_owned())) + }, CoinProtocol::UTXO { .. } | CoinProtocol::QTUM | CoinProtocol::QRC20 { .. } | CoinProtocol::BCH { .. } => { coins::utxo::address_by_conf_and_pubkey_str(coin, conf, pubkey, addr_format) .map(OrderbookAddress::Transparent) diff --git a/mm2src/mm2_main/src/lp_swap.rs b/mm2src/mm2_main/src/lp_swap.rs index 9e8dcee41c..685aeb867a 100644 --- a/mm2src/mm2_main/src/lp_swap.rs +++ b/mm2src/mm2_main/src/lp_swap.rs @@ -2335,10 +2335,11 @@ mod lp_swap_tests { let _: SavedSwap = json::from_str(include_str!("for_tests/iris_nimda_rick_maker_swap.json")).unwrap(); } + /// Tests WithBurn fee calculation when burn is enabled via mocking. + /// Verifies that non-discount coins use the same 2% fee rate and correct 75/25 fee/burn split. + /// Uses KMD and RICK (neither is a discount ticker) to avoid env var race with other tests. #[test] - fn test_kmd_taker_dex_fee_calculation() { - std::env::set_var("MYCOIN_FEE_DISCOUNT", ""); - + fn test_with_burn_fee_calculation() { let kmd = coins::TestCoin::new("KMD"); TestCoin::should_burn_dex_fee.mock_safe(|_| MockResult::Return(true)); let (kmd_fee_amount, kmd_burn_amount) = match DexFee::new_from_taker_coin(&kmd, "ETH", &MmNumber::from(6150)) { @@ -2353,65 +2354,84 @@ mod lp_swap_tests { }; TestCoin::should_burn_dex_fee.clear_mock(); - let mycoin = coins::TestCoin::new("MYCOIN"); + let rick = coins::TestCoin::new("RICK"); TestCoin::should_burn_dex_fee.mock_safe(|_| MockResult::Return(true)); - let (mycoin_fee_amount, mycoin_burn_amount) = - match DexFee::new_from_taker_coin(&mycoin, "ETH", &MmNumber::from(6150)) { - DexFee::Standard(_) | DexFee::NoFee => { - panic!("Wrong variant returned for MYCOIN from `DexFee::new_from_taker_coin`.") - }, - DexFee::WithBurn { - fee_amount, - burn_amount, - .. - } => (fee_amount, burn_amount), - }; + let (rick_fee_amount, rick_burn_amount) = match DexFee::new_from_taker_coin(&rick, "ETH", &MmNumber::from(6150)) + { + DexFee::Standard(_) | DexFee::NoFee => { + panic!("Wrong variant returned for RICK from `DexFee::new_from_taker_coin`.") + }, + DexFee::WithBurn { + fee_amount, + burn_amount, + .. + } => (fee_amount, burn_amount), + }; TestCoin::should_burn_dex_fee.clear_mock(); - let expected_mycoin_total_fee = &kmd_fee_amount / &MmNumber::from("0.75"); - let expected_kmd_burn_amount = &expected_mycoin_total_fee - &kmd_fee_amount; + // All non-discount coins should have the same fee rate (2%) + assert_eq!(kmd_fee_amount, rick_fee_amount); + assert_eq!(kmd_burn_amount, rick_burn_amount); - assert_eq!(kmd_fee_amount, mycoin_fee_amount); - assert_eq!(expected_kmd_burn_amount, kmd_burn_amount); - // assuming for TestCoin dust is zero - assert_eq!(mycoin_burn_amount, kmd_burn_amount); + // Verify fee/burn split: 75% fee, 25% burn + let total_fee = &kmd_fee_amount + &kmd_burn_amount; + let expected_fee = &total_fee * &MmNumber::from("0.75"); + let expected_burn = &total_fee * &MmNumber::from("0.25"); + assert_eq!(kmd_fee_amount, expected_fee); + assert_eq!(kmd_burn_amount, expected_burn); } + /// Tests that GLEEC trades get a 50% fee discount (1% vs 2% standard rate) + /// and that the 75/25 fee/burn split is applied on the discounted amount. #[test] - fn test_dex_fee_from_taker_coin_discount() { - std::env::set_var("MYCOIN_FEE_DISCOUNT", ""); - - let mycoin = coins::TestCoin::new("MYCOIN"); + fn test_dex_fee_burn_split_with_discount_and_standard_coins() { + let gleec = coins::TestCoin::new("GLEEC"); TestCoin::should_burn_dex_fee.mock_safe(|_| MockResult::Return(true)); - let (mycoin_taker_fee, mycoin_burn_amount) = - match DexFee::new_from_taker_coin(&mycoin, "", &MmNumber::from(6150)) { - DexFee::Standard(_) | DexFee::NoFee => { - panic!("Wrong variant returned for MYCOIN from `DexFee::new_from_taker_coin`.") - }, - DexFee::WithBurn { - fee_amount, - burn_amount, - .. - } => (fee_amount, burn_amount), - }; + let trade_amount = MmNumber::from(6150); + let (gleec_fee_amount, gleec_burn_amount) = match DexFee::new_from_taker_coin(&gleec, "ETH", &trade_amount) { + DexFee::Standard(_) | DexFee::NoFee => { + panic!("Wrong variant returned for GLEEC from `DexFee::new_from_taker_coin`.") + }, + DexFee::WithBurn { + fee_amount, + burn_amount, + .. + } => (fee_amount, burn_amount), + }; TestCoin::should_burn_dex_fee.clear_mock(); - let testcoin = coins::TestCoin::default(); + // GLEEC should use 1% rate (50% discount) + let total_gleec_fee = &gleec_fee_amount + &gleec_burn_amount; + let expected_total = &trade_amount * &MmNumber::from("0.01"); // 1% + assert_eq!(total_gleec_fee, expected_total); + + // Non-GLEEC should use 2% rate + let rick = coins::TestCoin::new("RICK"); TestCoin::should_burn_dex_fee.mock_safe(|_| MockResult::Return(true)); - let (testcoin_taker_fee, testcoin_burn_amount) = - match DexFee::new_from_taker_coin(&testcoin, "", &MmNumber::from(6150)) { - DexFee::Standard(_) | DexFee::NoFee => { - panic!("Wrong variant returned for TEST coin from `DexFee::new_from_taker_coin`.") - }, - DexFee::WithBurn { - fee_amount, - burn_amount, - .. - } => (fee_amount, burn_amount), - }; + let (rick_fee_amount, rick_burn_amount) = match DexFee::new_from_taker_coin(&rick, "ETH", &trade_amount) { + DexFee::Standard(_) | DexFee::NoFee => { + panic!("Wrong variant returned for RICK from `DexFee::new_from_taker_coin`.") + }, + DexFee::WithBurn { + fee_amount, + burn_amount, + .. + } => (fee_amount, burn_amount), + }; TestCoin::should_burn_dex_fee.clear_mock(); - assert_eq!(testcoin_taker_fee * MmNumber::from("0.90"), mycoin_taker_fee); - assert_eq!(testcoin_burn_amount * MmNumber::from("0.90"), mycoin_burn_amount); + + let total_rick_fee = &rick_fee_amount + &rick_burn_amount; + let expected_total = &trade_amount * &MmNumber::from("0.02"); // 2% + assert_eq!(total_rick_fee, expected_total); + + // Verify GLEEC fee is half of standard fee + assert_eq!(&total_gleec_fee * &MmNumber::from(2), total_rick_fee); + + // Verify fee/burn split: 75% fee, 25% burn + let expected_fee = &total_gleec_fee * &MmNumber::from("0.75"); + let expected_burn = &total_gleec_fee * &MmNumber::from("0.25"); + assert_eq!(gleec_fee_amount, expected_fee); + assert_eq!(gleec_burn_amount, expected_burn); } #[test] diff --git a/mm2src/mm2_main/src/lp_swap/check_balance.rs b/mm2src/mm2_main/src/lp_swap/check_balance.rs index 4af138b22c..ba384fa68c 100644 --- a/mm2src/mm2_main/src/lp_swap/check_balance.rs +++ b/mm2src/mm2_main/src/lp_swap/check_balance.rs @@ -274,9 +274,8 @@ impl CheckBalanceError { threshold, }, TradePreimageError::Transport(transport) => CheckBalanceError::Transport(transport), - TradePreimageError::InternalError(internal) => CheckBalanceError::InternalError(internal), - TradePreimageError::NftProtocolNotSupported => { - CheckBalanceError::InternalError("Nft Protocol is not supported yet!".to_string()) + TradePreimageError::InternalError(internal) | TradePreimageError::ProtocolNotSupported(internal) => { + CheckBalanceError::InternalError(internal) }, TradePreimageError::NoSuchCoin { .. } => CheckBalanceError::InternalError(trade_preimage_err.to_string()), } diff --git a/mm2src/mm2_main/src/lp_swap/maker_swap.rs b/mm2src/mm2_main/src/lp_swap/maker_swap.rs index 9663350e1d..a3fb5e0b6a 100644 --- a/mm2src/mm2_main/src/lp_swap/maker_swap.rs +++ b/mm2src/mm2_main/src/lp_swap/maker_swap.rs @@ -1576,7 +1576,6 @@ impl MakerSwap { search_from_block: taker_coin_start_block, swap_contract_address: &taker_coin_swap_contract_address, swap_unique_data: &unique_data, - watcher_reward, }; // check if the taker payment is not spent yet match selfi.taker_coin.search_for_swap_tx_spend_other(search_input).await { @@ -1671,7 +1670,6 @@ impl MakerSwap { search_from_block: maker_coin_start_block, swap_contract_address: &maker_coin_swap_contract_address, swap_unique_data: &unique_data, - watcher_reward, }; // validate that maker payment is not spent match self.maker_coin.search_for_swap_tx_spend_my(search_input).await { diff --git a/mm2src/mm2_main/src/lp_swap/maker_swap_v2.rs b/mm2src/mm2_main/src/lp_swap/maker_swap_v2.rs index ec6819727d..5117276a57 100644 --- a/mm2src/mm2_main/src/lp_swap/maker_swap_v2.rs +++ b/mm2src/mm2_main/src/lp_swap/maker_swap_v2.rs @@ -472,29 +472,29 @@ impl DexFee { - // Set DexFee::NoFee for swaps with KMD coin. - if self.maker_coin.ticker() == "KMD" || self.taker_coin.ticker() == "KMD" { - DexFee::NoFee - } else { - DexFee::new_from_taker_coin(&self.taker_coin, self.maker_coin.ticker(), &self.taker_volume) - } + // NOTE: To fully exempt a specific coin from dex fees, uncomment below: + // if self.maker_coin.ticker() == "GLEEC" || self.taker_coin.ticker() == "GLEEC" { + // return DexFee::NoFee; + // } + DexFee::new_from_taker_coin(&self.taker_coin, self.maker_coin.ticker(), &self.taker_volume) } - /// Calculate updated dex fee when taker pub is already received + /// Calculate updated dex fee when taker pub is already received. + /// GLEEC pairs get a 1% discount rate (applied inside dex_fee_rate). fn dex_fee_updated(&self, taker_pub: &[u8]) -> DexFee { - // Set DexFee::NoFee for swaps with KMD coin. - if self.maker_coin.ticker() == "KMD" || self.taker_coin.ticker() == "KMD" { - DexFee::NoFee - } else { - DexFee::new_with_taker_pubkey( - &self.taker_coin, - self.maker_coin.ticker(), - &self.taker_volume, - taker_pub, - ) - } + // NOTE: To fully exempt a specific coin from dex fees, uncomment below: + // if self.maker_coin.ticker() == "GLEEC" || self.taker_coin.ticker() == "GLEEC" { + // return DexFee::NoFee; + // } + DexFee::new_with_taker_pubkey( + &self.taker_coin, + self.maker_coin.ticker(), + &self.taker_volume, + taker_pub, + ) } } diff --git a/mm2src/mm2_main/src/lp_swap/recreate_swap_data.rs b/mm2src/mm2_main/src/lp_swap/recreate_swap_data.rs index 7ae7deb3e1..30b3823206 100644 --- a/mm2src/mm2_main/src/lp_swap/recreate_swap_data.rs +++ b/mm2src/mm2_main/src/lp_swap/recreate_swap_data.rs @@ -489,8 +489,7 @@ async fn convert_maker_to_taker_events( return events; }, MakerSwapEvent::TakerPaymentSpent(tx_ident) => { - //Is the watcher_reward argument important here? - let secret = match maker_coin.extract_secret(&secret_hash.0, &tx_ident.tx_hex, false).await { + let secret = match maker_coin.extract_secret(&secret_hash.0, &tx_ident.tx_hex).await { Ok(secret) => H256Json::from(secret), Err(e) => { push_event!(TakerSwapEvent::TakerPaymentWaitForSpendFailed(ERRL!("{}", e).into())); @@ -571,7 +570,7 @@ mod tests { #[test] fn test_recreate_taker_swap() { - TestCoin::extract_secret.mock_safe(|_coin, _secret_hash, _spend_tx, _watcher_reward| { + TestCoin::extract_secret.mock_safe(|_coin, _secret_hash, _spend_tx| { let secret = <[u8; 32]>::from_hex("23a6bb64bc0ab2cc14cb84277d8d25134b814e5f999c66e578c9bba3c5e2d3a4").unwrap(); MockResult::Return(Box::pin(async move { Ok(secret) })) diff --git a/mm2src/mm2_main/src/lp_swap/swap_watcher.rs b/mm2src/mm2_main/src/lp_swap/swap_watcher.rs index 758ad57204..7861b06ea9 100644 --- a/mm2src/mm2_main/src/lp_swap/swap_watcher.rs +++ b/mm2src/mm2_main/src/lp_swap/swap_watcher.rs @@ -396,7 +396,7 @@ impl State for WaitForTakerPaymentSpend { let tx_hex = tx.tx_hex(); let secret = match watcher_ctx .taker_coin - .extract_secret(&watcher_ctx.data.secret_hash, &tx_hex, true) + .extract_secret(&watcher_ctx.data.secret_hash, &tx_hex) .await { Ok(secret) => H256Json::from(secret), diff --git a/mm2src/mm2_main/src/lp_swap/taker_restart.rs b/mm2src/mm2_main/src/lp_swap/taker_restart.rs index 6e9f3624d2..7d712e1931 100644 --- a/mm2src/mm2_main/src/lp_swap/taker_restart.rs +++ b/mm2src/mm2_main/src/lp_swap/taker_restart.rs @@ -91,7 +91,6 @@ pub async fn check_maker_payment_spend_and_add_event( None => return ERR!("No info about maker payment, swap is not recoverable"), }; let unique_data = swap.unique_swap_data(); - let watcher_reward = swap.r().watcher_reward; let maker_payment_spend_tx = match swap.maker_coin .search_for_swap_tx_spend_other(SearchForSwapTxSpendInput { @@ -102,7 +101,6 @@ pub async fn check_maker_payment_spend_and_add_event( search_from_block: maker_coin_start_block, swap_contract_address: &maker_coin_swap_contract_address, swap_unique_data: &unique_data, - watcher_reward, }) .await { Ok(Some(FoundSwapTxSpend::Spent(maker_payment_spend_tx))) => maker_payment_spend_tx, @@ -159,7 +157,6 @@ pub async fn check_taker_payment_spend(swap: &TakerSwap) -> Result Result Result<(), String> { let secret_hash = swap.r().secret_hash.0.clone(); - let watcher_reward = swap.r().watcher_reward; let tx_hash = taker_payment_spend_tx.tx_hash_as_bytes(); info!("Taker payment spend tx {:02x}", tx_hash); @@ -189,11 +184,7 @@ pub async fn add_taker_payment_spent_event( tx_hex: Bytes::from(taker_payment_spend_tx.tx_hex()), tx_hash, }; - let secret = match swap - .taker_coin - .extract_secret(&secret_hash, &tx_ident.tx_hex, watcher_reward) - .await - { + let secret = match swap.taker_coin.extract_secret(&secret_hash, &tx_ident.tx_hex).await { Ok(secret) => H256::from(secret), Err(_) => { return ERR!("Could not extract secret from taker payment spend transaction"); diff --git a/mm2src/mm2_main/src/lp_swap/taker_swap.rs b/mm2src/mm2_main/src/lp_swap/taker_swap.rs index 62ebc2ceb0..275745d26a 100644 --- a/mm2src/mm2_main/src/lp_swap/taker_swap.rs +++ b/mm2src/mm2_main/src/lp_swap/taker_swap.rs @@ -597,7 +597,6 @@ pub struct TakerSwapMut { pub secret_hash: BytesJson, secret: H256Json, pub watcher_reward: bool, - reward_amount: Option, payment_instructions: Option, } @@ -839,6 +838,14 @@ impl TakerSwap { self.r().data.taker_payment_lock + 3700 } + #[inline] + fn watcher_reward_amount(&self) -> Option { + match &self.r().payment_instructions { + Some(PaymentInstructions::WatcherReward(reward)) => Some(reward.clone()), + _ => None, + } + } + pub(crate) fn apply_event(&self, event: TakerSwapEvent) { match event { TakerSwapEvent::Started(data) => { @@ -976,7 +983,6 @@ impl TakerSwap { secret_hash: BytesJson::default(), secret: H256Json::default(), watcher_reward: false, - reward_amount: None, payment_instructions: None, }), ctx, @@ -1551,7 +1557,7 @@ impl TakerSwap { } info!("After wait confirm"); - let reward_amount = self.r().reward_amount.clone(); + let reward_amount = self.watcher_reward_amount(); let wait_maker_payment_until = self.r().data.maker_payment_wait; let watcher_reward = if self.r().watcher_reward { match self @@ -1642,7 +1648,7 @@ impl TakerSwap { return Ok(None); } - let reward_amount = self.r().reward_amount.clone(); + let reward_amount = self.watcher_reward_amount(); self.taker_coin .get_taker_watcher_reward( &self.maker_coin, @@ -1940,11 +1946,7 @@ impl TakerSwap { tx_hash, }; - let secret = match self - .taker_coin - .extract_secret(&secret_hash.0, &tx_ident.tx_hex, watcher_reward) - .await - { + let secret = match self.taker_coin.extract_secret(&secret_hash.0, &tx_ident.tx_hex).await { Ok(secret) => H256Json::from(secret), Err(e) => { return Ok(( @@ -2331,7 +2333,6 @@ impl TakerSwap { search_from_block: maker_coin_start_block, swap_contract_address: &maker_coin_swap_contract_address, swap_unique_data: &unique_data, - watcher_reward, }; match self.maker_coin.search_for_swap_tx_spend_other(search_input).await { @@ -2436,7 +2437,6 @@ impl TakerSwap { search_from_block: taker_coin_start_block, swap_contract_address: &taker_coin_swap_contract_address, swap_unique_data: &unique_data, - watcher_reward, }; let taker_payment_spend = try_s!(self.taker_coin.search_for_swap_tx_spend_my(search_input).await); @@ -2446,11 +2446,7 @@ impl TakerSwap { check_maker_payment_is_not_spent!(); let secret_hash = self.r().secret_hash.clone(); let tx_hex = tx.tx_hex(); - let secret = try_s!( - self.taker_coin - .extract_secret(&secret_hash.0, &tx_hex, watcher_reward) - .await - ); + let secret = try_s!(self.taker_coin.extract_secret(&secret_hash.0, &tx_hex).await); let taker_spends_payment_args = SpendPaymentArgs { other_payment_tx: &maker_payment, @@ -2773,6 +2769,8 @@ pub async fn taker_swap_trade_preimage( #[derive(Deserialize)] struct MaxTakerVolRequest { coin: String, + /// Used for GLEEC fee discount calculation. + /// When trading with GLEEC, the fee rate is 1% instead of 2%. trade_with: Option, } @@ -3098,7 +3096,7 @@ mod taker_swap_tests { TestCoin::ticker.mock_safe(|_| MockResult::Return("ticker")); TestCoin::swap_contract_address.mock_safe(|_| MockResult::Return(None)); - TestCoin::extract_secret.mock_safe(|_, _, _, _| MockResult::Return(Box::pin(async move { Ok([0; 32]) }))); + TestCoin::extract_secret.mock_safe(|_, _, _| MockResult::Return(Box::pin(async move { Ok([0; 32]) }))); static MY_PAYMENT_SENT_CALLED: AtomicBool = AtomicBool::new(false); TestCoin::check_if_my_payment_sent.mock_safe(|_, _| { @@ -3225,7 +3223,7 @@ mod taker_swap_tests { TestCoin::ticker.mock_safe(|_| MockResult::Return("ticker")); TestCoin::swap_contract_address.mock_safe(|_| MockResult::Return(None)); - TestCoin::extract_secret.mock_safe(|_, _, _, _| MockResult::Return(Box::pin(async move { Ok([0; 32]) }))); + TestCoin::extract_secret.mock_safe(|_, _, _| MockResult::Return(Box::pin(async move { Ok([0; 32]) }))); static SEARCH_TX_SPEND_CALLED: AtomicBool = AtomicBool::new(false); TestCoin::search_for_swap_tx_spend_my.mock_safe(|_, _| { @@ -3379,26 +3377,39 @@ mod taker_swap_tests { assert!(!swap.is_recoverable()); } + /// Tests max_taker_vol_from_available with 2% standard fee rate and 1% GLEEC discount rate. + /// + /// With 2% fee rate (standard): + /// - fee = max(vol * 0.02, min_tx_amount) + /// - available = vol + fee + /// - boundary: vol * 0.02 == min_tx_amount => vol == 0.0005 => available == 0.00051 + /// + /// With 1% fee rate (GLEEC discount): + /// - fee = max(vol * 0.01, min_tx_amount) + /// - available = vol + fee + /// - boundary: vol * 0.01 == min_tx_amount => vol == 0.001 => available == 0.00101 #[test] fn test_max_taker_vol_from_available() { let min_tx_amount = MmNumber::from("0.00001"); // For these `availables` the dex_fee must be greater than min_tx_amount let source = vec![ - ("0.00779", false), + ("0.00052", false), + ("0.001", false), ("0.01", false), ("0.0135", false), ("1.2000001", false), ("99999999999999999999999999999999999999999999999999999", false), - ("0.00778000000000000000000000000000000000000000000000002", false), - ("0.00778000000000000000000000000000000000000000000000001", false), - ("0.00863333333333333333333333333333333333333333333333334", true), - ("0.00863333333333333333333333333333333333333333333333333", true), + ("0.00051000000000000000000000000000000000000000000000002", false), + ("0.00051000000000000000000000000000000000000000000000001", false), + // GLEEC discount (1% rate, boundary at 0.00101) + ("0.00102", true), + ("0.00101000000000000000000000000000000000000000000000001", true), ]; - for (available, is_kmd) in source { + for (available, is_gleec) in source { let available = MmNumber::from(available); - // no matter base or rel is KMD - let base = if is_kmd { "RICK" } else { "MORTY" }; + // no matter base or rel is GLEEC + let base = if is_gleec { "RICK" } else { "MORTY" }; let max_taker_vol = max_taker_vol_from_available(available.clone(), "RICK", "MORTY", &min_tx_amount) .expect("!max_taker_vol_from_available"); @@ -3411,18 +3422,20 @@ mod taker_swap_tests { assert_eq!(max_taker_vol + dex_fee, available); } - // for these `availables` the dex_fee must be the same as min_tx_amount + // For these `availables` the dex_fee must be the same as min_tx_amount let source = vec![ - ("0.00863333333333333333333333333333333333333333333333332", true), - ("0.00863333333333333333333333333333333333333333333333331", true), - ("0.00777999999999999999999999999999999999999999999999999", false), - ("0.00777", false), + // GLEEC discount (1% rate, boundary at 0.00101) + ("0.00101", true), + ("0.00100999999999999999999999999999999999999999999999999", true), + ("0.00051", false), + ("0.00050999999999999999999999999999999999999999999999999", false), + ("0.0003", false), ("0.00002001", false), ]; - for (available, is_kmd) in source { + for (available, is_gleec) in source { let available = MmNumber::from(available); - // no matter base or rel is KMD - let base = if is_kmd { "KMD" } else { "RICK" }; + // no matter base or rel is GLEEC + let base = if is_gleec { "GLEEC" } else { "RICK" }; let max_taker_vol = max_taker_vol_from_available(available.clone(), base, "MORTY", &min_tx_amount) .expect("!max_taker_vol_from_available"); @@ -3441,9 +3454,10 @@ mod taker_swap_tests { assert_eq!(max_taker_vol + dex_fee, available); } - // these `availables` must return an error + // These `availables` must return an error let availables = vec![ "0.00002", + "0.000019", "0.000011", "0.00001000000000000000000000000000000000000000000000001", "0.00001", @@ -3454,9 +3468,11 @@ mod taker_swap_tests { ]; for available in availables { let available = MmNumber::from(available); - max_taker_vol_from_available(available.clone(), "KMD", "MORTY", &min_tx_amount) + max_taker_vol_from_available(available.clone(), "GLEEC", "MORTY", &min_tx_amount) .expect_err("!max_taker_vol_from_available success but should be error"); } + + TestCoin::min_tx_amount.clear_mock(); } #[test] diff --git a/mm2src/mm2_main/src/lp_swap/taker_swap_v2.rs b/mm2src/mm2_main/src/lp_swap/taker_swap_v2.rs index 0298f3ac1a..75da1c3aee 100644 --- a/mm2src/mm2_main/src/lp_swap/taker_swap_v2.rs +++ b/mm2src/mm2_main/src/lp_swap/taker_swap_v2.rs @@ -489,11 +489,12 @@ impl DexFee { - if self.taker_coin.ticker() == "KMD" || self.maker_coin.ticker() == "KMD" { - // Set DexFee::NoFee for swaps with KMD coin. - return DexFee::NoFee; - } + // NOTE: To fully exempt a specific coin from dex fees, uncomment below: + // if self.taker_coin.ticker() == "GLEEC" || self.maker_coin.ticker() == "GLEEC" { + // return DexFee::NoFee; + // } if let Some(taker_pub) = self.taker_coin.taker_pubkey_bytes() { // for dex fee calculation we need only permanent (non-derived for HTLC) taker pubkey here diff --git a/mm2src/mm2_main/src/mm2.rs b/mm2src/mm2_main/src/mm2.rs index 9c36e9698a..77fc2f2d79 100644 --- a/mm2src/mm2_main/src/mm2.rs +++ b/mm2src/mm2_main/src/mm2.rs @@ -27,7 +27,10 @@ forgetting_copy_types, clippy::swap_ptr_to_ref, clippy::forget_non_drop, - clippy::let_unit_value + clippy::let_unit_value, + // TODO: Remove this allow when Rust 1.92 regression is fixed. + // See: https://github.com/rust-lang/rust/issues/147648 + unused_assignments )] #![cfg_attr(target_arch = "wasm32", allow(dead_code))] #![cfg_attr(target_arch = "wasm32", allow(unused_imports))] diff --git a/mm2src/mm2_main/src/ordermatch_tests.rs b/mm2src/mm2_main/src/ordermatch_tests.rs index 7fd01eaf43..506cdf939a 100644 --- a/mm2src/mm2_main/src/ordermatch_tests.rs +++ b/mm2src/mm2_main/src/ordermatch_tests.rs @@ -2455,6 +2455,22 @@ fn remove_order(ctx: &MmArc, uuid: Uuid) { }; } +/// Remove multiple orders in a single batch to avoid multiple synchronous flushes. +/// This is faster than calling remove_order() in a loop. +fn remove_orders_batch(ctx: &MmArc, uuids: impl IntoIterator) { + let ordermatch_ctx = OrdermatchContext::from_ctx(ctx).unwrap(); + let ops: Vec<_> = { + let mut orderbook = ordermatch_ctx.orderbook.lock(); + uuids + .into_iter() + .filter_map(|uuid| orderbook.index_remove(uuid).map(|(_removed, op)| op)) + .collect() + }; + if !ops.is_empty() { + let _ = ordermatch_ctx.trie_ops_tx.unbounded_send(ops); + } +} + /// Wait until the background trie worker has applied all pending ops. fn flush_trie(ctx: &MmArc) { let ordermatch_ctx = OrdermatchContext::from_ctx(ctx).unwrap(); @@ -2557,12 +2573,11 @@ fn test_process_sync_pubkey_orderbook_state_after_orders_removed() { let mut old_mem_db = clone_orderbook_memory_db(&ctx); - // pick 10 orders at random and remove them + // pick 10 orders at random and remove them in a single batch + // (batching avoids 10 synchronous flushes which can exceed the 3s history timeout on slow CI) let mut rng = thread_rng(); - let to_remove = orders.choose_multiple(&mut rng, 10); - for order in to_remove { - remove_order(&ctx, order.uuid); - } + let to_remove: Vec = orders.choose_multiple(&mut rng, 10).map(|o| o.uuid).collect(); + remove_orders_batch(&ctx, to_remove); flush_trie(&ctx); let mut result = process_sync_pubkey_orderbook_state(ctx.clone(), pubkey.clone(), prev_pairs_state) diff --git a/mm2src/mm2_main/src/rpc/lp_commands/lr_swap/lr_impl.rs b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap/lr_impl.rs index 70f19072b9..9e93d8e9aa 100644 --- a/mm2src/mm2_main/src/rpc/lp_commands/lr_swap/lr_impl.rs +++ b/mm2src/mm2_main/src/rpc/lp_commands/lr_swap/lr_impl.rs @@ -5,8 +5,7 @@ use crate::lp_ordermatch::RpcOrderbookEntryV2; use crate::rpc::lp_commands::lr_swap::types::{AskOrBidOrder, AsksForCoin, BidsForCoin}; use crate::rpc::lp_commands::one_inch::errors::ApiIntegrationRpcError; use crate::rpc::lp_commands::one_inch::rpcs::get_coin_for_one_inch; -use coins::eth::{mm_number_from_u256, mm_number_to_u256, wei_from_coins_mm_number}; -use coins::hd_wallet::DisplayAddress; +use coins::eth::{mm_number_from_u256, mm_number_to_u256, wei_from_coins_mm_number, ChainFamily}; use coins::lp_coinfind_or_err; use coins::MmCoin; use coins::Ticker; @@ -62,24 +61,21 @@ struct LrStepData { impl LrStepData { #[allow(clippy::result_large_err)] fn get_chain_contract_info(&self) -> MmResult<(String, String, u64), ApiIntegrationRpcError> { - let src_contract = self - .src_contract - .as_ref() - .ok_or(ApiIntegrationRpcError::InternalError( - "Source LR contract not set".to_owned(), - ))? - .display_address(); - let dst_contract = self - .dst_contract - .as_ref() - .ok_or(ApiIntegrationRpcError::InternalError( - "Destination LR contract not set".to_owned(), - ))? - .display_address(); + let src_contract = self.src_contract.as_ref().ok_or(ApiIntegrationRpcError::InternalError( + "Source LR contract not set".to_owned(), + ))?; + let dst_contract = self.dst_contract.as_ref().ok_or(ApiIntegrationRpcError::InternalError( + "Destination LR contract not set".to_owned(), + ))?; let chain_id = self .chain_id .ok_or(ApiIntegrationRpcError::InternalError("LR chain id not set".to_owned()))?; - Ok((src_contract, dst_contract, chain_id)) + // LR swaps are EVM-only, use EVM checksum formatting + Ok(( + ChainFamily::Evm.format(*src_contract), + ChainFamily::Evm.format(*dst_contract), + chain_id, + )) } } diff --git a/mm2src/mm2_main/src/rpc/lp_commands/one_inch/errors.rs b/mm2src/mm2_main/src/rpc/lp_commands/one_inch/errors.rs index 55dd8eef7b..705097aaf8 100644 --- a/mm2src/mm2_main/src/rpc/lp_commands/one_inch/errors.rs +++ b/mm2src/mm2_main/src/rpc/lp_commands/one_inch/errors.rs @@ -15,8 +15,7 @@ pub enum ApiIntegrationRpcError { }, #[display(fmt = "EVM token needed")] CoinTypeError, - #[display(fmt = "NFT not supported")] - NftProtocolNotSupported, + ProtocolNotSupported(String), #[display(fmt = "Chain not supported")] ChainNotSupported, #[display(fmt = "Must be same chain")] @@ -51,7 +50,7 @@ impl HttpStatusCode for ApiIntegrationRpcError { match self { ApiIntegrationRpcError::NoSuchCoin { .. } => StatusCode::NOT_FOUND, ApiIntegrationRpcError::CoinTypeError - | ApiIntegrationRpcError::NftProtocolNotSupported + | ApiIntegrationRpcError::ProtocolNotSupported(_) | ApiIntegrationRpcError::ChainNotSupported | ApiIntegrationRpcError::DifferentChains | ApiIntegrationRpcError::MyAddressError(_) diff --git a/mm2src/mm2_main/src/rpc/lp_commands/one_inch/rpcs.rs b/mm2src/mm2_main/src/rpc/lp_commands/one_inch/rpcs.rs index 820ce2c580..e3abfdd6d9 100644 --- a/mm2src/mm2_main/src/rpc/lp_commands/one_inch/rpcs.rs +++ b/mm2src/mm2_main/src/rpc/lp_commands/one_inch/rpcs.rs @@ -4,7 +4,7 @@ use super::types::{ ClassicSwapLiquiditySourcesResponse, ClassicSwapQuoteRequest, ClassicSwapResponse, ClassicSwapTokensRequest, ClassicSwapTokensResponse, }; -use coins::eth::{u256_from_big_decimal, EthCoin, EthCoinType}; +use coins::eth::{u256_from_big_decimal, ChainFamily, EthCoin, EthCoinType}; use coins::hd_wallet::DisplayAddress; use coins::{lp_coinfind_or_err, CoinWithDerivationMethod, MmCoin, MmCoinEnum, Ticker}; use ethereum_types::Address; @@ -38,8 +38,8 @@ pub async fn one_inch_v6_0_classic_swap_quote_rpc( let sell_amount = u256_from_big_decimal(&req.amount.to_decimal(), base.decimals()) .mm_err(|err| ApiIntegrationRpcError::InvalidParam(err.to_string()))?; let query_params = ClassicSwapQuoteParams::new( - base_contract.display_address(), - rel_contract.display_address(), + ChainFamily::Evm.format(base_contract), + ChainFamily::Evm.format(rel_contract), sell_amount.to_string(), ) .with_fee(req.fee) @@ -83,8 +83,8 @@ pub async fn one_inch_v6_0_classic_swap_create_rpc( let single_address = base.derivation_method().single_addr_or_err().await.map_mm_err()?; let query_params = ClassicSwapCreateParams::new( - base_contract.display_address(), - rel_contract.display_address(), + ChainFamily::Evm.format(base_contract), + ChainFamily::Evm.format(rel_contract), sell_amount.to_string(), single_address.display_address(), req.slippage, @@ -165,7 +165,12 @@ pub(crate) async fn get_coin_for_one_inch( EthCoinType::Eth => Address::from_str(ApiClient::eth_special_contract()) .map_to_mm(|_| ApiIntegrationRpcError::InternalError("invalid address".to_owned()))?, EthCoinType::Erc20 { token_addr, .. } => token_addr, - EthCoinType::Nft { .. } => return Err(MmError::new(ApiIntegrationRpcError::NftProtocolNotSupported)), + EthCoinType::Nft { .. } => { + return Err(MmError::new(ApiIntegrationRpcError::ProtocolNotSupported(format!( + "{} protocol is not supported by get_coin_for_one_inch", + coin.coin_type + )))) + }, }; Ok((coin, contract)) } diff --git a/mm2src/mm2_main/tests/docker_tests/docker_ordermatch_tests.rs b/mm2src/mm2_main/tests/docker_tests/docker_ordermatch_tests.rs index c852e2b638..df2604cab2 100644 --- a/mm2src/mm2_main/tests/docker_tests/docker_ordermatch_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/docker_ordermatch_tests.rs @@ -1,21 +1,16 @@ -use crate::docker_tests::docker_tests_common::{generate_utxo_coin_with_privkey, GETH_RPC_URL}; -use crate::docker_tests::eth_docker_tests::{fill_eth_erc20_with_private_key, swap_contract}; +use crate::docker_tests::helpers::utxo::generate_utxo_coin_with_random_privkey; use crate::integration_tests_common::enable_native; -use crate::{generate_utxo_coin_with_random_privkey, random_secp256k1_secret}; use common::block_on; use mm2_number::BigDecimal; use mm2_rpc::data::legacy::OrderbookResponse; -use mm2_test_helpers::for_tests::{ - best_orders_v2, best_orders_v2_by_number, enable_eth_coin, eth_dev_conf, mm_dump, my_balance, mycoin1_conf, - mycoin_conf, MarketMakerIt, Mm2TestConf, -}; +use mm2_test_helpers::for_tests::{mm_dump, MarketMakerIt}; use mm2_test_helpers::structs::{ BestOrdersResponse, BestOrdersV2Response, BuyOrSellRpcResult, MyOrdersRpcResult, OrderbookDepthResponse, RpcV2Response, SetPriceResponse, }; -use serde_json::Value as Json; +use serde_json::{json, Value as Json}; use std::thread; use std::time::Duration; @@ -731,447 +726,10 @@ fn test_ordermatch_custom_orderbook_ticker_mixed_case_two() { block_on(mm_alice.stop()).unwrap(); } -fn get_bob_alice() -> (MarketMakerIt, MarketMakerIt) { - let bob_priv_key = random_secp256k1_secret(); - let alice_priv_key = random_secp256k1_secret(); - - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), bob_priv_key); - generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), bob_priv_key); - fill_eth_erc20_with_private_key(bob_priv_key); - - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), alice_priv_key); - generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), alice_priv_key); - fill_eth_erc20_with_private_key(alice_priv_key); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000), eth_dev_conf(),]); - - let bob_conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(bob_priv_key)), &coins); - let mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); - - let (_bob_dump_log, _bob_dump_dashboard) = mm_bob.mm_dump(); - log!("Bob log path: {}", mm_bob.log_path.display()); - - let alice_conf = Mm2TestConf::light_node( - &format!("0x{}", hex::encode(alice_priv_key)), - &coins, - &[&mm_bob.ip.to_string()], - ); - let mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); - - let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); - log!("Alice log path: {}", mm_alice.log_path.display()); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - dbg!(block_on(enable_eth_coin( - &mm_bob, - "ETH", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); - dbg!(block_on(enable_eth_coin( - &mm_alice, - "ETH", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); - - (mm_bob, mm_alice) -} - -#[test] -fn test_best_orders() { - let (mut mm_bob, mm_alice) = get_bob_alice(); - - // issue sell request on Bob side by setting base/rel price - log!("Issue bob sell requests"); - - let bob_orders = [ - // (base, rel, price, volume, min_volume) - ("MYCOIN", "MYCOIN1", "0.9", "0.9", None), - ("MYCOIN", "MYCOIN1", "0.8", "0.9", None), - ("MYCOIN", "MYCOIN1", "0.7", "0.9", Some("0.9")), - ("MYCOIN", "ETH", "0.8", "0.9", None), - ("MYCOIN1", "MYCOIN", "0.8", "0.9", None), - ("MYCOIN1", "MYCOIN", "0.9", "0.9", None), - ("ETH", "MYCOIN", "0.8", "0.9", None), - ("MYCOIN1", "ETH", "0.8", "0.8", None), - ("MYCOIN1", "ETH", "0.7", "0.8", Some("0.8")), - ]; - for (base, rel, price, volume, min_volume) in bob_orders.iter() { - let rc = block_on(mm_bob.rpc(&json! ({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": base, - "rel": rel, - "price": price, - "volume": volume, - "min_volume": min_volume.unwrap_or("0.00777"), - "cancel_previous": false, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - } - - block_on(mm_bob.wait_for_log(22., |log| { - log.contains("DEBUG Handling IncludedTorelaysMesh message for peer") - })) - .unwrap(); - - let rc = block_on(mm_alice.rpc(&json! ({ - "userpass": mm_alice.userpass, - "method": "best_orders", - "coin": "MYCOIN", - "action": "buy", - "volume": "0.1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!best_orders: {}", rc.1); - let response: BestOrdersResponse = serde_json::from_str(&rc.1).unwrap(); - let best_mycoin1_orders = response.result.get("MYCOIN1").unwrap(); - assert_eq!(1, best_mycoin1_orders.len()); - let expected_price: BigDecimal = "0.8".parse().unwrap(); - assert_eq!(expected_price, best_mycoin1_orders[0].price); - - let rc = block_on(mm_alice.rpc(&json! ({ - "userpass": mm_alice.userpass, - "method": "best_orders", - "coin": "MYCOIN", - "action": "buy", - "volume": "1.7", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!best_orders: {}", rc.1); - let response: BestOrdersResponse = serde_json::from_str(&rc.1).unwrap(); - // MYCOIN1 - let best_mycoin1_orders = response.result.get("MYCOIN1").unwrap(); - let expected_price: BigDecimal = "0.7".parse().unwrap(); - let bob_mycoin1_addr = block_on(my_balance(&mm_bob, "MYCOIN1")).address; - // let bob_mycoin1_addr = mm_bob.display_address("MYCOIN1").unwrap(); - assert_eq!(expected_price, best_mycoin1_orders[0].price); - assert_eq!(bob_mycoin1_addr, best_mycoin1_orders[0].address); - let expected_price: BigDecimal = "0.8".parse().unwrap(); - assert_eq!(expected_price, best_mycoin1_orders[1].price); - assert_eq!(bob_mycoin1_addr, best_mycoin1_orders[1].address); - // ETH - let expected_price: BigDecimal = "0.8".parse().unwrap(); - let best_eth_orders = response.result.get("ETH").unwrap(); - assert_eq!(expected_price, best_eth_orders[0].price); - - let rc = block_on(mm_alice.rpc(&json! ({ - "userpass": mm_alice.userpass, - "method": "best_orders", - "coin": "MYCOIN", - "action": "sell", - "volume": "0.1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!best_orders: {}", rc.1); - let response: BestOrdersResponse = serde_json::from_str(&rc.1).unwrap(); - - let expected_price: BigDecimal = "1.25".parse().unwrap(); - - let best_mycoin1_orders = response.result.get("MYCOIN1").unwrap(); - assert_eq!(expected_price, best_mycoin1_orders[0].price); - assert_eq!(1, best_mycoin1_orders.len()); - - let best_eth_orders = response.result.get("ETH").unwrap(); - assert_eq!(expected_price, best_eth_orders[0].price); - - let rc = block_on(mm_alice.rpc(&json! ({ - "userpass": mm_alice.userpass, - "method": "best_orders", - "coin": "ETH", - "action": "sell", - "volume": "0.1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!best_orders: {}", rc.1); - let response: BestOrdersResponse = serde_json::from_str(&rc.1).unwrap(); - - let expected_price: BigDecimal = "1.25".parse().unwrap(); - - let best_mycoin1_orders = response.result.get("MYCOIN1").unwrap(); - assert_eq!(expected_price, best_mycoin1_orders[0].price); - assert_eq!("MYCOIN1", best_mycoin1_orders[0].coin); - assert_eq!(1, best_mycoin1_orders.len()); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -fn test_best_orders_v2_by_number() { - let (mut mm_bob, mm_alice) = get_bob_alice(); - - // issue sell request on Bob side by setting base/rel price - log!("Issue bob sell requests"); - - let bob_orders = [ - // (base, rel, price, volume, min_volume) - ("MYCOIN", "MYCOIN1", "0.9", "0.9", None), - ("MYCOIN", "MYCOIN1", "0.8", "0.9", None), - ("MYCOIN", "MYCOIN1", "0.7", "0.9", Some("0.9")), - ("MYCOIN", "ETH", "0.8", "0.9", None), - ("MYCOIN1", "MYCOIN", "0.8", "0.9", None), - ("MYCOIN1", "MYCOIN", "0.9", "0.9", None), - ("ETH", "MYCOIN", "0.8", "0.9", None), - ("MYCOIN1", "ETH", "0.8", "0.8", None), - ("MYCOIN1", "ETH", "0.7", "0.8", Some("0.8")), - ]; - for (base, rel, price, volume, min_volume) in bob_orders.iter() { - let rc = block_on(mm_bob.rpc(&json! ({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": base, - "rel": rel, - "price": price, - "volume": volume, - "min_volume": min_volume.unwrap_or("0.00777"), - "cancel_previous": false, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - } - - block_on(mm_bob.wait_for_log(22., |log| { - log.contains("DEBUG Handling IncludedTorelaysMesh message for peer") - })) - .unwrap(); - - let response = block_on(best_orders_v2_by_number(&mm_alice, "MYCOIN", "buy", 1, false)); - log!("response {response:?}"); - let best_mycoin1_orders = response.result.orders.get("MYCOIN1").unwrap(); - log!("Best MYCOIN1 orders when buy MYCOIN {:?}", [best_mycoin1_orders]); - assert_eq!(1, best_mycoin1_orders.len()); - let expected_price: BigDecimal = "0.7".parse().unwrap(); - assert_eq!(expected_price, best_mycoin1_orders[0].price.decimal); - - let response = block_on(best_orders_v2_by_number(&mm_alice, "MYCOIN", "buy", 2, false)); - log!("response {response:?}"); - let best_mycoin1_orders = response.result.orders.get("MYCOIN1").unwrap(); - log!("Best MYCOIN1 orders when buy MYCOIN {:?}", [best_mycoin1_orders]); - assert_eq!(2, best_mycoin1_orders.len()); - let expected_price: BigDecimal = "0.7".parse().unwrap(); - assert_eq!(expected_price, best_mycoin1_orders[0].price.decimal); - let expected_price: BigDecimal = "0.8".parse().unwrap(); - assert_eq!(expected_price, best_mycoin1_orders[1].price.decimal); - - let response = block_on(best_orders_v2_by_number(&mm_alice, "MYCOIN", "sell", 1, false)); - log!("response {response:?}"); - let expected_price: BigDecimal = "1.25".parse().unwrap(); - let best_mycoin1_orders = response.result.orders.get("MYCOIN1").unwrap(); - log!("Best MYCOIN1 orders when sell MYCOIN {:?}", [best_mycoin1_orders]); - assert_eq!(1, best_mycoin1_orders.len()); - assert_eq!(expected_price, best_mycoin1_orders[0].price.decimal); - let best_eth_orders = response.result.orders.get("ETH").unwrap(); - log!("Best ETH orders when sell MYCOIN {:?}", [best_eth_orders]); - assert_eq!(1, best_eth_orders.len()); - assert_eq!(expected_price, best_eth_orders[0].price.decimal); - - let response = block_on(best_orders_v2_by_number(&mm_alice, "ETH", "sell", 1, false)); - log!("response {response:?}"); - let best_mycoin_orders = response.result.orders.get("MYCOIN").unwrap(); - log!("Best MYCOIN orders when sell ETH {:?}", [best_mycoin_orders]); - assert_eq!(1, best_mycoin_orders.len()); - let expected_price: BigDecimal = "1.25".parse().unwrap(); - assert_eq!(expected_price, best_mycoin_orders[0].price.decimal); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -fn test_best_orders_v2_by_volume() { - let (mut mm_bob, mm_alice) = get_bob_alice(); - - // issue sell request on Bob side by setting base/rel price - log!("Issue bob sell requests"); - - let bob_orders = [ - // (base, rel, price, volume, min_volume) - ("MYCOIN", "MYCOIN1", "0.9", "0.9", None), - ("MYCOIN", "MYCOIN1", "0.8", "0.9", None), - ("MYCOIN", "MYCOIN1", "0.7", "0.9", Some("0.9")), - ("MYCOIN", "ETH", "0.8", "0.9", None), - ("MYCOIN1", "MYCOIN", "0.8", "0.9", None), - ("MYCOIN1", "MYCOIN", "0.9", "0.9", None), - ("ETH", "MYCOIN", "0.8", "0.9", None), - ("MYCOIN1", "ETH", "0.8", "0.8", None), - ("MYCOIN1", "ETH", "0.7", "0.8", Some("0.8")), - ]; - for (base, rel, price, volume, min_volume) in bob_orders.iter() { - let rc = block_on(mm_bob.rpc(&json! ({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": base, - "rel": rel, - "price": price, - "volume": volume, - "min_volume": min_volume.unwrap_or("0.00777"), - "cancel_previous": false, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - } - - block_on(mm_bob.wait_for_log(22., |log| { - log.contains("DEBUG Handling IncludedTorelaysMesh message for peer") - })) - .unwrap(); - - let response = block_on(best_orders_v2(&mm_alice, "MYCOIN", "buy", "1.7")); - log!("response {response:?}"); - // MYCOIN1 - let best_mycoin1_orders = response.result.orders.get("MYCOIN1").unwrap(); - log!("Best MYCOIN1 orders when buy MYCOIN {:?}", [best_mycoin1_orders]); - let expected_price: BigDecimal = "0.7".parse().unwrap(); - assert_eq!(expected_price, best_mycoin1_orders[0].price.decimal); - let expected_price: BigDecimal = "0.8".parse().unwrap(); - assert_eq!(expected_price, best_mycoin1_orders[1].price.decimal); - // ETH - let expected_price: BigDecimal = "0.8".parse().unwrap(); - let best_eth_orders = response.result.orders.get("ETH").unwrap(); - log!("Best ETH orders when buy MYCOIN {:?}", [best_eth_orders]); - assert_eq!(expected_price, best_eth_orders[0].price.decimal); - - let response = block_on(best_orders_v2(&mm_alice, "MYCOIN", "sell", "0.1")); - log!("response {response:?}"); - let expected_price: BigDecimal = "1.25".parse().unwrap(); - let best_mycoin1_orders = response.result.orders.get("MYCOIN1").unwrap(); - log!("Best MYCOIN1 orders when sell MYCOIN {:?}", [best_mycoin1_orders]); - assert_eq!(expected_price, best_mycoin1_orders[0].price.decimal); - assert_eq!(1, best_mycoin1_orders.len()); - let best_eth_orders = response.result.orders.get("ETH").unwrap(); - log!("Best ETH orders when sell MYCOIN {:?}", [best_mycoin1_orders]); - assert_eq!(expected_price, best_eth_orders[0].price.decimal); - - let response = block_on(best_orders_v2(&mm_alice, "ETH", "sell", "0.1")); - log!("response {response:?}"); - let expected_price: BigDecimal = "1.25".parse().unwrap(); - let best_mycoin1_orders = response.result.orders.get("MYCOIN1").unwrap(); - log!("Best MYCOIN1 orders when sell ETH {:?}", [best_mycoin1_orders]); - assert_eq!(expected_price, best_mycoin1_orders[0].price.decimal); - assert_eq!("MYCOIN1", best_mycoin1_orders[0].coin); - assert_eq!(1, best_mycoin1_orders.len()); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -fn test_best_orders_filter_response() { - // alice defined MYCOIN1 as "wallet_only" in config - let alice_coins = json!([ - mycoin_conf(1000), - {"coin":"MYCOIN1","asset":"MYCOIN1","rpcport":11608,"wallet_only": true,"txversion":4,"overwintered":1,"protocol":{"type":"UTXO"}}, - eth_dev_conf(), - ]); - - let bob_priv_key = random_secp256k1_secret(); - let alice_priv_key = random_secp256k1_secret(); - - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), bob_priv_key); - generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), bob_priv_key); - fill_eth_erc20_with_private_key(bob_priv_key); - - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), alice_priv_key); - generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), alice_priv_key); - fill_eth_erc20_with_private_key(alice_priv_key); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000), eth_dev_conf(),]); - - let bob_conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(bob_priv_key)), &coins); - let mut mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); - - let (_bob_dump_log, _bob_dump_dashboard) = mm_bob.mm_dump(); - log!("Bob log path: {}", mm_bob.log_path.display()); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - dbg!(block_on(enable_eth_coin( - &mm_bob, - "ETH", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); - - // issue sell request on Bob side by setting base/rel price - log!("Issue bob sell requests"); - - let bob_orders = [ - // (base, rel, price, volume, min_volume) - ("MYCOIN", "MYCOIN1", "0.9", "0.9", None), - ("MYCOIN", "MYCOIN1", "0.8", "0.9", None), - ("MYCOIN", "MYCOIN1", "0.7", "0.9", Some("0.9")), - ("MYCOIN", "ETH", "0.8", "0.9", None), - ("MYCOIN1", "MYCOIN", "0.8", "0.9", None), - ("MYCOIN1", "MYCOIN", "0.9", "0.9", None), - ("ETH", "MYCOIN", "0.8", "0.9", None), - ("MYCOIN1", "ETH", "0.8", "0.8", None), - ("MYCOIN1", "ETH", "0.7", "0.8", Some("0.8")), - ]; - for (base, rel, price, volume, min_volume) in bob_orders.iter() { - let rc = block_on(mm_bob.rpc(&json! ({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": base, - "rel": rel, - "price": price, - "volume": volume, - "min_volume": min_volume.unwrap_or("0.00777"), - "cancel_previous": false, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - } - - let alice_conf = Mm2TestConf::light_node( - &format!("0x{}", hex::encode(alice_priv_key)), - &alice_coins, - &[&mm_bob.ip.to_string()], - ); - let mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); - - let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); - log!("Alice log path: {}", mm_alice.log_path.display()); - - block_on(mm_bob.wait_for_log(22., |log| { - log.contains("DEBUG Handling IncludedTorelaysMesh message for peer") - })) - .unwrap(); - - let rc = block_on(mm_alice.rpc(&json! ({ - "userpass": mm_alice.userpass, - "method": "best_orders", - "coin": "MYCOIN", - "action": "buy", - "volume": "0.1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!best_orders: {}", rc.1); - let response: BestOrdersResponse = serde_json::from_str(&rc.1).unwrap(); - let empty_vec = Vec::new(); - let best_mycoin1_orders = response.result.get("MYCOIN1").unwrap_or(&empty_vec); - assert_eq!(0, best_mycoin1_orders.len()); - let best_eth_orders = response.result.get("ETH").unwrap(); - assert_eq!(1, best_eth_orders.len()); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} +// ============================================================================= +// UTXO-only Orderbook Zombie Tests +// These tests verify order lifecycle and state management using only UTXO coins +// ============================================================================= // https://github.com/KomodoPlatform/atomicDEX-API/issues/1148 // here 'zombie' means 'unusable order' diff --git a/mm2src/mm2_main/tests/docker_tests/docker_tests_common.rs b/mm2src/mm2_main/tests/docker_tests/docker_tests_common.rs deleted file mode 100644 index 514532f31f..0000000000 --- a/mm2src/mm2_main/tests/docker_tests/docker_tests_common.rs +++ /dev/null @@ -1,1773 +0,0 @@ -use super::eth_docker_tests::{erc20_contract_checksum, fill_eth, fill_eth_erc20_with_private_key, swap_contract}; -use super::z_coin_docker_tests::z_coin_from_spending_key; -use bitcrypto::{dhash160, ChecksumType}; -use chain::TransactionOutput; -use coins::eth::addr_from_raw_pubkey; -use coins::qrc20::rpc_clients::for_tests::Qrc20NativeWalletOps; -use coins::qrc20::{qrc20_coin_with_priv_key, Qrc20ActivationParams, Qrc20Coin}; -use coins::utxo::bch::{bch_coin_with_priv_key, BchActivationRequest, BchCoin}; -use coins::utxo::qtum::{qtum_coin_with_priv_key, QtumBasedCoin, QtumCoin}; -use coins::utxo::rpc_clients::{NativeClient, UtxoRpcClientEnum, UtxoRpcClientOps}; -use coins::utxo::slp::{slp_genesis_output, SlpOutput, SlpToken}; -use coins::utxo::utxo_common::send_outputs_from_my_address; -use coins::utxo::utxo_standard::{utxo_standard_coin_with_priv_key, UtxoStandardCoin}; -use coins::utxo::{ - coin_daemon_data_dir, sat_from_big_decimal, zcash_params_path, UtxoActivationParams, UtxoAddressFormat, - UtxoCoinFields, UtxoCommonOps, -}; -use coins::z_coin::ZCoin; -use coins::{ConfirmPaymentInput, MarketCoinOps, Transaction}; -use common::executor::Timer; -use common::Future01CompatExt; -pub use common::{block_on, block_on_f01, now_ms, now_sec, wait_until_ms, wait_until_sec}; -use crypto::privkey::{key_pair_from_secret, key_pair_from_seed}; -use crypto::Secp256k1Secret; -use ethabi::Token; -use ethereum_types::{H160 as H160Eth, U256}; -use futures::TryFutureExt; -use http::StatusCode; -use keys::{ - Address, AddressBuilder, AddressHashEnum, AddressPrefix, KeyPair, NetworkAddressPrefixes, - NetworkPrefix as CashAddrPrefix, -}; -use mm2_core::mm_ctx::{MmArc, MmCtxBuilder}; -use mm2_number::BigDecimal; -pub use mm2_number::MmNumber; -use mm2_rpc::data::legacy::BalanceResponse; -pub use mm2_test_helpers::for_tests::{ - check_my_swap_status, check_recent_swaps, enable_eth_coin, enable_native, enable_native_bch, erc20_dev_conf, - eth_dev_conf, mm_dump, wait_check_stats_swap_status, MarketMakerIt, -}; -use mm2_test_helpers::get_passphrase; -use mm2_test_helpers::structs::TransactionDetails; -use primitives::hash::{H160, H256}; -use script::Builder; -use secp256k1::Secp256k1; -pub use secp256k1::{PublicKey, SecretKey}; -use serde_json::{self as json, Value as Json}; -pub use std::cell::Cell; -use std::convert::TryFrom; -use std::process::{Command, Stdio}; -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -use std::str::FromStr; -pub use std::{env, thread}; -use std::{path::PathBuf, sync::Mutex, time::Duration}; -use testcontainers::core::Mount; -use testcontainers::runners::SyncRunner; -use testcontainers::{core::WaitFor, Container, GenericImage, RunnableImage}; -use tokio::sync::Mutex as AsyncMutex; -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -use web3::types::Address as EthAddress; -use web3::types::{BlockId, BlockNumber, TransactionRequest}; -use web3::{transports::Http, Web3}; - -lazy_static! { - static ref MY_COIN_LOCK: AsyncMutex<()> = AsyncMutex::new(()); - static ref MY_COIN1_LOCK: AsyncMutex<()> = AsyncMutex::new(()); - static ref QTUM_LOCK: AsyncMutex<()> = AsyncMutex::new(()); - static ref FOR_SLP_LOCK: AsyncMutex<()> = AsyncMutex::new(()); - pub static ref SLP_TOKEN_ID: Mutex = Mutex::new(H256::default()); - // Private keys supplied with 1000 SLP tokens on tests initialization. - // Due to the SLP protocol limitations only 19 outputs (18 + change) can be sent in one transaction, which is sufficient for now though. - // Supply more privkeys when 18 will be not enough. - pub static ref SLP_TOKEN_OWNERS: Mutex> = Mutex::new(Vec::with_capacity(18)); - pub static ref MM_CTX: MmArc = MmCtxBuilder::new().with_conf(json!({"coins":[eth_dev_conf()],"use_trading_proto_v2": true})).into_mm_arc(); - /// We need a second `MmCtx` instance when we use the same private keys for Maker and Taker across various tests. - /// When enabling coins for both Maker and Taker, two distinct coin instances are created. - /// This means that different instances of the same coin should have separate global nonce locks. - /// Utilizing different `MmCtx` instances allows us to assign Maker and Taker coins to separate `CoinsCtx`. - /// This approach addresses the `replacement transaction` issue, which occurs when different transactions share the same nonce. - pub static ref MM_CTX1: MmArc = MmCtxBuilder::new().with_conf(json!({"use_trading_proto_v2": true})).into_mm_arc(); - pub static ref GETH_WEB3: Web3 = Web3::new(Http::new(GETH_RPC_URL).unwrap()); - // Mutex used to prevent nonce re-usage during funding addresses used in tests - pub static ref GETH_NONCE_LOCK: Mutex<()> = Mutex::new(()); -} - -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -lazy_static! { - pub static ref SEPOLIA_WEB3: Web3 = Web3::new(Http::new(SEPOLIA_RPC_URL).unwrap()); - pub static ref SEPOLIA_NONCE_LOCK: Mutex<()> = Mutex::new(()); - pub static ref SEPOLIA_TESTS_LOCK: Mutex<()> = Mutex::new(()); -} - -pub static mut QICK_TOKEN_ADDRESS: Option = None; -pub static mut QORTY_TOKEN_ADDRESS: Option = None; -pub static mut QRC20_SWAP_CONTRACT_ADDRESS: Option = None; -pub static mut QTUM_CONF_PATH: Option = None; -/// The account supplied with ETH on Geth dev node creation -pub static mut GETH_ACCOUNT: H160Eth = H160Eth::zero(); -/// ERC20 token address on Geth dev node -pub static mut GETH_ERC20_CONTRACT: H160Eth = H160Eth::zero(); -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -pub static mut SEPOLIA_ERC20_CONTRACT: H160Eth = H160Eth::zero(); -/// Swap contract address on Geth dev node -pub static mut GETH_SWAP_CONTRACT: H160Eth = H160Eth::zero(); -/// Maker Swap V2 contract address on Geth dev node -pub static mut GETH_MAKER_SWAP_V2: H160Eth = H160Eth::zero(); -/// Taker Swap V2 contract address on Geth dev node -pub static mut GETH_TAKER_SWAP_V2: H160Eth = H160Eth::zero(); -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -pub static mut SEPOLIA_TAKER_SWAP_V2: H160Eth = H160Eth::zero(); -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -pub static mut SEPOLIA_MAKER_SWAP_V2: H160Eth = H160Eth::zero(); -/// Swap contract (with watchers support) address on Geth dev node -pub static mut GETH_WATCHERS_SWAP_CONTRACT: H160Eth = H160Eth::zero(); -/// ERC721 token address on Geth dev node -pub static mut GETH_ERC721_CONTRACT: H160Eth = H160Eth::zero(); -/// ERC1155 token address on Geth dev node -pub static mut GETH_ERC1155_CONTRACT: H160Eth = H160Eth::zero(); -/// NFT Maker Swap V2 contract address on Geth dev node -pub static mut GETH_NFT_MAKER_SWAP_V2: H160Eth = H160Eth::zero(); -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -/// NFT Maker Swap V2 contract address on Sepolia testnet -pub static mut SEPOLIA_ETOMIC_MAKER_NFT_SWAP_V2: H160Eth = H160Eth::zero(); -pub static GETH_RPC_URL: &str = "http://127.0.0.1:8545"; -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -pub static SEPOLIA_RPC_URL: &str = "https://ethereum-sepolia-rpc.publicnode.com"; -/// SIA daemon RPC connection parameters -pub static SIA_RPC_PARAMS: (&str, u16, &str) = ("127.0.0.1", 9980, "password"); - -// use thread local to affect only the current running test -thread_local! { - /// Set test dex pubkey as Taker (to check DexFee::NoFee) - pub static SET_BURN_PUBKEY_TO_ALICE: Cell = const { Cell::new(false) }; -} - -pub const UTXO_ASSET_DOCKER_IMAGE: &str = "docker.io/artempikulin/testblockchain"; -pub const UTXO_ASSET_DOCKER_IMAGE_WITH_TAG: &str = "docker.io/artempikulin/testblockchain:multiarch"; -pub const GETH_DOCKER_IMAGE: &str = "docker.io/ethereum/client-go"; -pub const GETH_DOCKER_IMAGE_WITH_TAG: &str = "docker.io/ethereum/client-go:stable"; -pub const ZOMBIE_ASSET_DOCKER_IMAGE: &str = "docker.io/borngraced/zombietestrunner"; -pub const ZOMBIE_ASSET_DOCKER_IMAGE_WITH_TAG: &str = "docker.io/borngraced/zombietestrunner:multiarch"; - -pub const SIA_DOCKER_IMAGE: &str = "ghcr.io/siafoundation/walletd"; -pub const SIA_DOCKER_IMAGE_WITH_TAG: &str = "ghcr.io/siafoundation/walletd:latest"; - -pub const NUCLEUS_IMAGE: &str = "docker.io/komodoofficial/nucleusd"; -pub const ATOM_IMAGE_WITH_TAG: &str = "docker.io/komodoofficial/gaiad:kdf-ci"; -pub const IBC_RELAYER_IMAGE_WITH_TAG: &str = "docker.io/komodoofficial/ibc-relayer:kdf-ci"; - -pub const QTUM_ADDRESS_LABEL: &str = "MM2_ADDRESS_LABEL"; - -/// ERC721_TEST_TOKEN has additional mint function -/// https://github.com/KomodoPlatform/etomic-swap/blob/public-mint-nft-functions/contracts/Erc721Token.sol (see public-mint-nft-functions branch) -pub const ERC721_TEST_ABI: &str = include_str!("../../../mm2_test_helpers/dummy_files/erc721_test_abi.json"); -/// ERC1155_TEST_TOKEN has additional mint function -/// https://github.com/KomodoPlatform/etomic-swap/blob/public-mint-nft-functions/contracts/Erc1155Token.sol (see public-mint-nft-functions branch) -pub const ERC1155_TEST_ABI: &str = include_str!("../../../mm2_test_helpers/dummy_files/erc1155_test_abi.json"); - -/// Ticker of MYCOIN dockerized blockchain. -pub const MYCOIN: &str = "MYCOIN"; -/// Ticker of MYCOIN1 dockerized blockchain. -pub const MYCOIN1: &str = "MYCOIN1"; - -pub const ERC20_TOKEN_BYTES: &str = include_str!("../../../mm2_test_helpers/contract_bytes/erc20_token_bytes"); -pub const SWAP_CONTRACT_BYTES: &str = include_str!("../../../mm2_test_helpers/contract_bytes/swap_contract_bytes"); -pub const WATCHERS_SWAP_CONTRACT_BYTES: &str = - include_str!("../../../mm2_test_helpers/contract_bytes/watchers_swap_contract_bytes"); -/// https://github.com/KomodoPlatform/etomic-swap/blob/7d4eafd4a408188a95aee78a41f0bf5f9116ffa2/contracts/Erc721Token.sol -pub const ERC721_TEST_TOKEN_BYTES: &str = - include_str!("../../../mm2_test_helpers/contract_bytes/erc721_test_token_bytes"); -/// https://github.com/KomodoPlatform/etomic-swap/blob/7d4eafd4a408188a95aee78a41f0bf5f9116ffa2/contracts/Erc1155Token.sol -pub const ERC1155_TEST_TOKEN_BYTES: &str = - include_str!("../../../mm2_test_helpers/contract_bytes/erc1155_test_token_bytes"); -/// https://github.com/KomodoPlatform/etomic-swap/blob/7d4eafd4a408188a95aee78a41f0bf5f9116ffa2/contracts/EtomicSwapMakerNftV2.sol -pub const NFT_MAKER_SWAP_V2_BYTES: &str = - include_str!("../../../mm2_test_helpers/contract_bytes/nft_maker_swap_v2_bytes"); -/// https://github.com/KomodoPlatform/etomic-swap/blob/7d4eafd4a408188a95aee78a41f0bf5f9116ffa2/contracts/EtomicSwapMakerV2.sol -pub const MAKER_SWAP_V2_BYTES: &str = include_str!("../../../mm2_test_helpers/contract_bytes/maker_swap_v2_bytes"); -/// https://github.com/KomodoPlatform/etomic-swap/blob/7d4eafd4a408188a95aee78a41f0bf5f9116ffa2/contracts/EtomicSwapTakerV2.sol -pub const TAKER_SWAP_V2_BYTES: &str = include_str!("../../../mm2_test_helpers/contract_bytes/taker_swap_v2_bytes"); - -pub trait CoinDockerOps { - fn rpc_client(&self) -> &UtxoRpcClientEnum; - - fn native_client(&self) -> &NativeClient { - match self.rpc_client() { - UtxoRpcClientEnum::Native(native) => native, - _ => panic!("UtxoRpcClientEnum::Native is expected"), - } - } - - fn wait_ready(&self, expected_tx_version: i32) { - let timeout = wait_until_ms(120000); - loop { - match block_on_f01(self.rpc_client().get_block_count()) { - Ok(n) => { - if n > 1 { - if let UtxoRpcClientEnum::Native(client) = self.rpc_client() { - let hash = block_on_f01(client.get_block_hash(n)).unwrap(); - let block = block_on_f01(client.get_block(hash)).unwrap(); - let coinbase = block_on_f01(client.get_verbose_transaction(&block.tx[0])).unwrap(); - log!("Coinbase tx {:?} in block {}", coinbase, n); - if coinbase.version == expected_tx_version { - break; - } - } - } - }, - Err(e) => log!("{:?}", e), - } - assert!(now_ms() < timeout, "Test timed out"); - thread::sleep(Duration::from_secs(1)); - } - } -} - -pub struct UtxoAssetDockerOps { - #[allow(dead_code)] - ctx: MmArc, - coin: UtxoStandardCoin, -} - -impl CoinDockerOps for UtxoAssetDockerOps { - fn rpc_client(&self) -> &UtxoRpcClientEnum { - &self.coin.as_ref().rpc_client - } -} - -impl UtxoAssetDockerOps { - pub fn from_ticker(ticker: &str) -> UtxoAssetDockerOps { - let conf = json!({"coin": ticker, "asset": ticker, "txfee": 1000, "network": "regtest"}); - let req = json!({"method":"enable"}); - let priv_key = Secp256k1Secret::from("809465b17d0a4ddb3e4c69e8f23c2cabad868f51f8bed5c765ad1d6516c3306f"); - let ctx = MmCtxBuilder::new().into_mm_arc(); - let params = UtxoActivationParams::from_legacy_req(&req).unwrap(); - - let coin = block_on(utxo_standard_coin_with_priv_key(&ctx, ticker, &conf, ¶ms, priv_key)).unwrap(); - UtxoAssetDockerOps { ctx, coin } - } -} - -pub struct ZCoinAssetDockerOps { - #[allow(dead_code)] - ctx: MmArc, - coin: ZCoin, -} - -impl CoinDockerOps for ZCoinAssetDockerOps { - fn rpc_client(&self) -> &UtxoRpcClientEnum { - &self.coin.as_ref().rpc_client - } -} - -impl ZCoinAssetDockerOps { - pub fn new() -> ZCoinAssetDockerOps { - let (ctx, coin) = block_on(z_coin_from_spending_key("secret-extended-key-main1q0k2ga2cqqqqpq8m8j6yl0say83cagrqp53zqz54w38ezs8ly9ly5ptamqwfpq85u87w0df4k8t2lwyde3n9v0gcr69nu4ryv60t0kfcsvkr8h83skwqex2nf0vr32794fmzk89cpmjptzc22lgu5wfhhp8lgf3f5vn2l3sge0udvxnm95k6dtxj2jwlfyccnum7nz297ecyhmd5ph526pxndww0rqq0qly84l635mec0x4yedf95hzn6kcgq8yxts26k98j9g32kjc8y83fe", "fe")); - - ZCoinAssetDockerOps { ctx, coin } - } -} - -pub struct BchDockerOps { - #[allow(dead_code)] - ctx: MmArc, - coin: BchCoin, -} - -impl BchDockerOps { - pub fn from_ticker(ticker: &str) -> BchDockerOps { - let conf = - json!({"coin": ticker,"asset": ticker,"txfee":1000,"network": "regtest","txversion":4,"overwintered":1}); - let req = json!({"method":"enable", "bchd_urls": [], "allow_slp_unsafe_conf": true}); - let priv_key = Secp256k1Secret::from("809465b17d0a4ddb3e4c69e8f23c2cabad868f51f8bed5c765ad1d6516c3306f"); - let ctx = MmCtxBuilder::new().into_mm_arc(); - let params = BchActivationRequest::from_legacy_req(&req).unwrap(); - - let coin = block_on(bch_coin_with_priv_key( - &ctx, - ticker, - &conf, - params, - CashAddrPrefix::SlpTest, - priv_key, - )) - .unwrap(); - BchDockerOps { ctx, coin } - } - - pub fn initialize_slp(&self) { - fill_address(&self.coin, &self.coin.my_address().unwrap(), 100000.into(), 30); - let mut slp_privkeys = vec![]; - - let slp_genesis_op_ret = slp_genesis_output("ADEXSLP", "ADEXSLP", None, None, 8, None, 1000000_00000000); - let slp_genesis = TransactionOutput { - value: self.coin.as_ref().dust_amount, - script_pubkey: Builder::build_p2pkh(&self.coin.my_public_key().unwrap().address_hash().into()).to_bytes(), - }; - - let mut bch_outputs = vec![slp_genesis_op_ret, slp_genesis]; - let mut slp_outputs = vec![]; - - for _ in 0..18 { - let key_pair = KeyPair::random_compressed(); - let address = AddressBuilder::new( - Default::default(), - Default::default(), - self.coin.as_ref().conf.address_prefixes.clone(), - None, - ) - .as_pkh_from_pk(*key_pair.public()) - .build() - .expect("valid address props"); - - block_on_f01( - self.native_client() - .import_address(&address.to_string(), &address.to_string(), false), - ) - .unwrap(); - - let script_pubkey = Builder::build_p2pkh(&key_pair.public().address_hash().into()); - - bch_outputs.push(TransactionOutput { - value: 1000_00000000, - script_pubkey: script_pubkey.to_bytes(), - }); - - slp_outputs.push(SlpOutput { - amount: 1000_00000000, - script_pubkey: script_pubkey.to_bytes(), - }); - slp_privkeys.push(*key_pair.private_ref()); - } - - let slp_genesis_tx = block_on_f01(send_outputs_from_my_address(self.coin.clone(), bch_outputs)).unwrap(); - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: slp_genesis_tx.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: wait_until_sec(30), - check_every: 1, - }; - block_on_f01(self.coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - - let adex_slp = SlpToken::new( - 8, - "ADEXSLP".into(), - <&[u8; 32]>::try_from(slp_genesis_tx.tx_hash_as_bytes().as_slice()) - .unwrap() - .into(), - self.coin.clone(), - 1, - ) - .unwrap(); - - let tx = block_on(adex_slp.send_slp_outputs(slp_outputs)).unwrap(); - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: tx.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: wait_until_sec(30), - check_every: 1, - }; - block_on_f01(self.coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - *SLP_TOKEN_OWNERS.lock().unwrap() = slp_privkeys; - *SLP_TOKEN_ID.lock().unwrap() = <[u8; 32]>::try_from(slp_genesis_tx.tx_hash_as_bytes().as_slice()) - .unwrap() - .into(); - } -} - -impl CoinDockerOps for BchDockerOps { - fn rpc_client(&self) -> &UtxoRpcClientEnum { - &self.coin.as_ref().rpc_client - } -} - -pub struct DockerNode { - #[allow(dead_code)] - pub container: Container, - #[allow(dead_code)] - pub ticker: String, - #[allow(dead_code)] - pub port: u16, -} - -pub fn random_secp256k1_secret() -> Secp256k1Secret { - let priv_key = SecretKey::new(&mut rand6::thread_rng()); - Secp256k1Secret::from(*priv_key.as_ref()) -} - -pub fn utxo_asset_docker_node(ticker: &'static str, port: u16) -> DockerNode { - let image = GenericImage::new(UTXO_ASSET_DOCKER_IMAGE, "multiarch") - .with_mount(Mount::bind_mount( - zcash_params_path().display().to_string(), - "/root/.zcash-params", - )) - .with_env_var("CLIENTS", "2") - .with_env_var("CHAIN", ticker) - .with_env_var("TEST_ADDY", "R9imXLs1hEcU9KbFDQq2hJEEJ1P5UoekaF") - .with_env_var("TEST_WIF", "UqqW7f766rADem9heD8vSBvvrdfJb3zg5r8du9rJxPtccjWf7RG9") - .with_env_var( - "TEST_PUBKEY", - "021607076d7a2cb148d542fb9644c04ffc22d2cca752f80755a0402a24c567b17a", - ) - .with_env_var("DAEMON_URL", "http://test:test@127.0.0.1:7000") - .with_env_var("COIN", "Komodo") - .with_env_var("COIN_RPC_PORT", port.to_string()) - .with_wait_for(WaitFor::message_on_stdout("config is ready")); - let image = RunnableImage::from(image).with_mapped_port((port, port)); - let container = image.start().expect("Failed to start UTXO asset docker node"); - let mut conf_path = coin_daemon_data_dir(ticker, true); - std::fs::create_dir_all(&conf_path).unwrap(); - conf_path.push(format!("{ticker}.conf")); - Command::new("docker") - .arg("cp") - .arg(format!("{}:/data/node_0/{}.conf", container.id(), ticker)) - .arg(&conf_path) - .status() - .expect("Failed to execute docker command"); - let timeout = wait_until_ms(3000); - loop { - if conf_path.exists() { - break; - }; - assert!(now_ms() < timeout, "Test timed out"); - } - DockerNode { - container, - ticker: ticker.into(), - port, - } -} - -pub fn geth_docker_node(ticker: &'static str, port: u16) -> DockerNode { - let image = GenericImage::new(GETH_DOCKER_IMAGE, "stable"); - let args = vec!["--dev".into(), "--http".into(), "--http.addr=0.0.0.0".into()]; - let image = RunnableImage::from((image, args)).with_mapped_port((port, port)); - let container = image.start().expect("Failed to start Geth docker node"); - DockerNode { - container, - ticker: ticker.into(), - port, - } -} - -pub fn sia_docker_node(ticker: &'static str, port: u16) -> DockerNode { - use crate::sia_tests::utils::{WALLETD_CONFIG, WALLETD_NETWORK_CONFIG}; - - let config_dir = std::env::temp_dir() - .join(format!( - "sia-docker-tests-temp-{}", - chrono::Local::now().format("%Y-%m-%d_%H-%M-%S-%3f") - )) - .join("walletd_config"); - std::fs::create_dir_all(&config_dir).unwrap(); - - // Write walletd.yml - std::fs::write(config_dir.join("walletd.yml"), WALLETD_CONFIG).expect("failed to write walletd.yml"); - - // Write ci_network.json - std::fs::write(config_dir.join("ci_network.json"), WALLETD_NETWORK_CONFIG) - .expect("failed to write ci_network.json"); - - let image = GenericImage::new(SIA_DOCKER_IMAGE, "latest") - .with_env_var("WALLETD_CONFIG_FILE", "/config/walletd.yml") - .with_wait_for(WaitFor::message_on_stdout("node started")) - .with_mount(Mount::bind_mount( - config_dir.to_str().expect("config path is invalid"), - "/config", - )); - - let args = vec!["-network=/config/ci_network.json".to_string(), "-debug".to_string()]; - let image = RunnableImage::from(image) - .with_mapped_port((port, port)) - .with_args(args); - - let container = image.start().expect("Failed to start Sia docker node"); - DockerNode { - container, - ticker: ticker.into(), - port, - } -} - -pub fn nucleus_node(runtime_dir: PathBuf) -> DockerNode { - let nucleus_node_runtime_dir = runtime_dir.join("nucleus-testnet-data"); - assert!(nucleus_node_runtime_dir.exists()); - - let image = GenericImage::new(NUCLEUS_IMAGE, "latest").with_mount(Mount::bind_mount( - nucleus_node_runtime_dir.to_str().unwrap(), - "/root/.nucleus", - )); - let image = RunnableImage::from((image, vec![])).with_network("host"); - let container = image.start().expect("Failed to start Nucleus docker node"); - - DockerNode { - container, - ticker: "NUCLEUS-TEST".to_owned(), - port: Default::default(), // This doesn't need to be the correct value as we are using the host network. - } -} - -pub fn atom_node(runtime_dir: PathBuf) -> DockerNode { - let atom_node_runtime_dir = runtime_dir.join("atom-testnet-data"); - assert!(atom_node_runtime_dir.exists()); - - let (image, tag) = ATOM_IMAGE_WITH_TAG.rsplit_once(':').unwrap(); - let image = GenericImage::new(image, tag).with_mount(Mount::bind_mount( - atom_node_runtime_dir.to_str().unwrap(), - "/root/.gaia", - )); - let image = RunnableImage::from((image, vec![])).with_network("host"); - let container = image.start().expect("Failed to start Atom docker node"); - - DockerNode { - container, - ticker: "ATOM-TEST".to_owned(), - port: Default::default(), // This doesn't need to be the correct value as we are using the host network. - } -} - -pub fn ibc_relayer_node(runtime_dir: PathBuf) -> DockerNode { - let relayer_node_runtime_dir = runtime_dir.join("ibc-relayer-data"); - assert!(relayer_node_runtime_dir.exists()); - - let (image, tag) = IBC_RELAYER_IMAGE_WITH_TAG.rsplit_once(':').unwrap(); - let image = GenericImage::new(image, tag).with_mount(Mount::bind_mount( - relayer_node_runtime_dir.to_str().unwrap(), - "/root/.relayer", - )); - let image = RunnableImage::from((image, vec![])).with_network("host"); - let container = image.start().expect("Failed to start IBC Relayer docker node"); - - DockerNode { - container, - ticker: Default::default(), // This isn't an asset node. - port: Default::default(), // This doesn't need to be the correct value as we are using the host network. - } -} - -pub fn zombie_asset_docker_node(port: u16) -> DockerNode { - let image = GenericImage::new(ZOMBIE_ASSET_DOCKER_IMAGE, "multiarch") - .with_mount(Mount::bind_mount( - zcash_params_path().display().to_string(), - "/root/.zcash-params", - )) - .with_env_var("COIN_RPC_PORT", port.to_string()) - .with_wait_for(WaitFor::message_on_stdout("config is ready")); - - let image = RunnableImage::from(image).with_mapped_port((port, port)); - let container = image.start().expect("Failed to start Zombie asset docker node"); - let config_ticker = "ZOMBIE"; - let mut conf_path = coin_daemon_data_dir(config_ticker, true); - - std::fs::create_dir_all(&conf_path).unwrap(); - conf_path.push(format!("{config_ticker}.conf")); - Command::new("docker") - .arg("cp") - .arg(format!("{}:/data/node_0/{}.conf", container.id(), config_ticker)) - .arg(&conf_path) - .status() - .expect("Failed to execute docker command"); - - let timeout = wait_until_ms(3000); - while !conf_path.exists() { - assert!(now_ms() < timeout, "Test timed out"); - } - - DockerNode { - container, - ticker: config_ticker.into(), - port, - } -} - -pub fn rmd160_from_priv(privkey: Secp256k1Secret) -> H160 { - let secret = SecretKey::from_slice(privkey.as_slice()).unwrap(); - let public = PublicKey::from_secret_key(&Secp256k1::new(), &secret); - dhash160(&public.serialize()) -} - -pub fn get_prefilled_slp_privkey() -> [u8; 32] { - SLP_TOKEN_OWNERS.lock().unwrap().remove(0) -} - -pub fn get_slp_token_id() -> String { - hex::encode(SLP_TOKEN_ID.lock().unwrap().as_slice()) -} - -pub async fn import_address(coin: &T) -where - T: MarketCoinOps + AsRef, -{ - let mutex = match coin.ticker() { - "MYCOIN" => &*MY_COIN_LOCK, - "MYCOIN1" => &*MY_COIN1_LOCK, - "QTUM" | "QICK" | "QORTY" => &*QTUM_LOCK, - "FORSLP" => &*FOR_SLP_LOCK, - ticker => panic!("Unknown ticker {}", ticker), - }; - let _lock = mutex.lock().await; - - match coin.as_ref().rpc_client { - UtxoRpcClientEnum::Native(ref native) => { - let my_address = coin.my_address().unwrap(); - native - .import_address(&my_address, &my_address, false) - .compat() - .await - .unwrap(); - }, - UtxoRpcClientEnum::Electrum(_) => panic!("Expected NativeClient"), - } -} - -/// Build `Qrc20Coin` from ticker and privkey without filling the balance. -pub fn qrc20_coin_from_privkey(ticker: &str, priv_key: Secp256k1Secret) -> (MmArc, Qrc20Coin) { - let (contract_address, swap_contract_address) = unsafe { - let contract_address = match ticker { - "QICK" => QICK_TOKEN_ADDRESS.expect("QICK_TOKEN_ADDRESS must be set already"), - "QORTY" => QORTY_TOKEN_ADDRESS.expect("QORTY_TOKEN_ADDRESS must be set already"), - _ => panic!("Expected QICK or QORTY ticker"), - }; - ( - contract_address, - QRC20_SWAP_CONTRACT_ADDRESS.expect("QRC20_SWAP_CONTRACT_ADDRESS must be set already"), - ) - }; - let platform = "QTUM"; - let ctx = MmCtxBuilder::new().into_mm_arc(); - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; - let conf = json!({ - "coin":ticker, - "decimals": 8, - "required_confirmations":0, - "pubtype":120, - "p2shtype":110, - "wiftype":128, - "mm2":1, - "mature_confirmations":500, - "network":"regtest", - "confpath": confpath, - "dust": 72800, - }); - let req = json!({ - "method": "enable", - "swap_contract_address": format!("{:#02x}", swap_contract_address), - }); - let params = Qrc20ActivationParams::from_legacy_req(&req).unwrap(); - - let coin = block_on(qrc20_coin_with_priv_key( - &ctx, - ticker, - platform, - &conf, - ¶ms, - priv_key, - contract_address, - )) - .unwrap(); - - block_on(import_address(&coin)); - (ctx, coin) -} - -fn qrc20_coin_conf_item(ticker: &str) -> Json { - let contract_address = unsafe { - match ticker { - "QICK" => QICK_TOKEN_ADDRESS.expect("QICK_TOKEN_ADDRESS must be set already"), - "QORTY" => QORTY_TOKEN_ADDRESS.expect("QORTY_TOKEN_ADDRESS must be set already"), - _ => panic!("Expected either QICK or QORTY ticker, found {}", ticker), - } - }; - let contract_address = format!("{contract_address:#02x}"); - - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; - json!({ - "coin":ticker, - "required_confirmations":1, - "pubtype":120, - "p2shtype":110, - "wiftype":128, - "mature_confirmations":500, - "confpath":confpath, - "network":"regtest", - "protocol":{"type":"QRC20","protocol_data":{"platform":"QTUM","contract_address":contract_address}}}) -} - -/// Build asset `UtxoStandardCoin` from ticker and privkey without filling the balance. -pub fn utxo_coin_from_privkey(ticker: &str, priv_key: Secp256k1Secret) -> (MmArc, UtxoStandardCoin) { - let ctx = MmCtxBuilder::new().into_mm_arc(); - let conf = json!({"coin":ticker,"asset":ticker,"txversion":4,"overwintered":1,"txfee":1000,"network":"regtest"}); - let req = json!({"method":"enable"}); - let params = UtxoActivationParams::from_legacy_req(&req).unwrap(); - let coin = block_on(utxo_standard_coin_with_priv_key(&ctx, ticker, &conf, ¶ms, priv_key)).unwrap(); - block_on(import_address(&coin)); - (ctx, coin) -} - -/// Create a UTXO coin for the given privkey and fill it's address with the specified balance. -pub fn generate_utxo_coin_with_privkey(ticker: &str, balance: BigDecimal, priv_key: Secp256k1Secret) { - let (_, coin) = utxo_coin_from_privkey(ticker, priv_key); - let timeout = 30; // timeout if test takes more than 30 seconds to run - let my_address = coin.my_address().expect("!my_address"); - fill_address(&coin, &my_address, balance, timeout); -} - -pub async fn fund_privkey_utxo(ticker: &str, balance: BigDecimal, priv_key: &Secp256k1Secret) { - let ctx = MmCtxBuilder::new().into_mm_arc(); - let conf = json!({"coin":ticker,"asset":ticker,"txversion":4,"overwintered":1,"txfee":1000,"network":"regtest"}); - let req = json!({"method":"enable"}); - let params = UtxoActivationParams::from_legacy_req(&req).unwrap(); - let coin = utxo_standard_coin_with_priv_key(&ctx, ticker, &conf, ¶ms, *priv_key) - .await - .unwrap(); - let my_address = coin.my_address().expect("!my_address"); - fill_address_async(&coin, &my_address, balance, 30).await; -} - -/// Generate random privkey, create a UTXO coin and fill it's address with the specified balance. -pub fn generate_utxo_coin_with_random_privkey( - ticker: &str, - balance: BigDecimal, -) -> (MmArc, UtxoStandardCoin, Secp256k1Secret) { - let priv_key = random_secp256k1_secret(); - let (ctx, coin) = utxo_coin_from_privkey(ticker, priv_key); - let timeout = 30; // timeout if test takes more than 30 seconds to run - let my_address = coin.my_address().expect("!my_address"); - fill_address(&coin, &my_address, balance, timeout); - (ctx, coin, priv_key) -} - -/// Get only one address assigned the specified label. -pub fn get_address_by_label(coin: T, label: &str) -> String -where - T: AsRef, -{ - let native = match coin.as_ref().rpc_client { - UtxoRpcClientEnum::Native(ref native) => native, - UtxoRpcClientEnum::Electrum(_) => panic!("NativeClient expected"), - }; - let mut addresses = block_on_f01(native.get_addresses_by_label(label)) - .expect("!getaddressesbylabel") - .into_iter(); - match addresses.next() { - Some((addr, _purpose)) if addresses.next().is_none() => addr, - Some(_) => panic!("Expected only one address by {:?}", label), - None => panic!("Expected one address by {:?}", label), - } -} - -pub fn fill_qrc20_address(coin: &Qrc20Coin, amount: BigDecimal, timeout: u64) { - // prevent concurrent fill since daemon RPC returns errors if send_to_address - // is called concurrently (insufficient funds) and it also may return other errors - // if previous transaction is not confirmed yet - let _lock = block_on(QTUM_LOCK.lock()); - let timeout = wait_until_sec(timeout); - let client = match coin.as_ref().rpc_client { - UtxoRpcClientEnum::Native(ref client) => client, - UtxoRpcClientEnum::Electrum(_) => panic!("Expected NativeClient"), - }; - - let from_addr = get_address_by_label(coin, QTUM_ADDRESS_LABEL); - let to_addr = block_on_f01(coin.my_addr_as_contract_addr().compat()).unwrap(); - let satoshis = sat_from_big_decimal(&amount, coin.as_ref().decimals).expect("!sat_from_big_decimal"); - - let hash = block_on_f01(client.transfer_tokens( - &coin.contract_address, - &from_addr, - to_addr, - satoshis.into(), - coin.as_ref().decimals, - )) - .expect("!transfer_tokens") - .txid; - - let tx_bytes = block_on_f01(client.get_transaction_bytes(&hash)).unwrap(); - log!("{:02x}", tx_bytes); - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: tx_bytes.0, - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); -} - -/// Generate random privkey, create a QRC20 coin and fill it's address with the specified balance. -pub fn generate_qrc20_coin_with_random_privkey( - ticker: &str, - qtum_balance: BigDecimal, - qrc20_balance: BigDecimal, -) -> (MmArc, Qrc20Coin, Secp256k1Secret) { - let priv_key = random_secp256k1_secret(); - let (ctx, coin) = qrc20_coin_from_privkey(ticker, priv_key); - - let timeout = 30; // timeout if test takes more than 30 seconds to run - let my_address = coin.my_address().expect("!my_address"); - fill_address(&coin, &my_address, qtum_balance, timeout); - fill_qrc20_address(&coin, qrc20_balance, timeout); - (ctx, coin, priv_key) -} - -pub fn generate_qtum_coin_with_random_privkey( - ticker: &str, - balance: BigDecimal, - txfee: Option, -) -> (MmArc, QtumCoin, [u8; 32]) { - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; - let conf = json!({ - "coin":ticker, - "decimals":8, - "required_confirmations":0, - "pubtype":120, - "p2shtype": 110, - "wiftype":128, - "txfee": txfee, - "txfee_volatility_percent":0.1, - "mm2":1, - "mature_confirmations":500, - "network":"regtest", - "confpath": confpath, - "dust": 72800, - }); - let req = json!({"method": "enable"}); - let priv_key = random_secp256k1_secret(); - let ctx = MmCtxBuilder::new().into_mm_arc(); - let params = UtxoActivationParams::from_legacy_req(&req).unwrap(); - let coin = block_on(qtum_coin_with_priv_key(&ctx, ticker, &conf, ¶ms, priv_key)).unwrap(); - - let timeout = 30; // timeout if test takes more than 30 seconds to run - let my_address = coin.my_address().expect("!my_address"); - fill_address(&coin, &my_address, balance, timeout); - (ctx, coin, priv_key.take()) -} - -pub fn generate_segwit_qtum_coin_with_random_privkey( - ticker: &str, - balance: BigDecimal, - txfee: Option, -) -> (MmArc, QtumCoin, Secp256k1Secret) { - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; - let conf = json!({ - "coin":ticker, - "decimals":8, - "required_confirmations":0, - "pubtype":120, - "p2shtype": 110, - "wiftype":128, - "segwit":true, - "txfee": txfee, - "txfee_volatility_percent":0.1, - "mm2":1, - "mature_confirmations":500, - "network":"regtest", - "confpath": confpath, - "dust": 72800, - "bech32_hrp":"qcrt", - "address_format": { - "format": "segwit", - }, - }); - let req = json!({"method": "enable"}); - let priv_key = random_secp256k1_secret(); - let ctx = MmCtxBuilder::new().into_mm_arc(); - let params = UtxoActivationParams::from_legacy_req(&req).unwrap(); - let coin = block_on(qtum_coin_with_priv_key(&ctx, ticker, &conf, ¶ms, priv_key)).unwrap(); - - let timeout = 30; // timeout if test takes more than 30 seconds to run - let my_address = coin.my_address().expect("!my_address"); - fill_address(&coin, &my_address, balance, timeout); - (ctx, coin, priv_key) -} - -pub fn fill_address(coin: &T, address: &str, amount: BigDecimal, timeout: u64) -where - T: MarketCoinOps + AsRef, -{ - block_on(fill_address_async(coin, address, amount, timeout)); -} - -pub async fn fill_address_async(coin: &T, address: &str, amount: BigDecimal, timeout: u64) -where - T: MarketCoinOps + AsRef, -{ - // prevent concurrent fill since daemon RPC returns errors if send_to_address - // is called concurrently (insufficient funds) and it also may return other errors - // if previous transaction is not confirmed yet - let mutex = match coin.ticker() { - "MYCOIN" => &*MY_COIN_LOCK, - "MYCOIN1" => &*MY_COIN1_LOCK, - "QTUM" | "QICK" | "QORTY" => &*QTUM_LOCK, - "FORSLP" => &*FOR_SLP_LOCK, - ticker => panic!("Unknown ticker {}", ticker), - }; - let _lock = mutex.lock().await; - let timeout = wait_until_sec(timeout); - - if let UtxoRpcClientEnum::Native(client) = &coin.as_ref().rpc_client { - client.import_address(address, address, false).compat().await.unwrap(); - let hash = client.send_to_address(address, &amount).compat().await.unwrap(); - let tx_bytes = client.get_transaction_bytes(&hash).compat().await.unwrap(); - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: tx_bytes.clone().0, - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - coin.wait_for_confirmations(confirm_payment_input) - .compat() - .await - .unwrap(); - log!("{:02x}", tx_bytes); - loop { - let unspents = client - .list_unspent_impl(0, i32::MAX, vec![address.to_string()]) - .compat() - .await - .unwrap(); - if !unspents.is_empty() { - break; - } - assert!(now_sec() < timeout, "Test timed out"); - Timer::sleep(1.0).await; - } - }; -} - -/// Wait for the `estimatesmartfee` returns no errors. -pub fn wait_for_estimate_smart_fee(timeout: u64) -> Result<(), String> { - enum EstimateSmartFeeState { - Idle, - Ok, - NotAvailable, - } - lazy_static! { - static ref LOCK: Mutex = Mutex::new(EstimateSmartFeeState::Idle); - } - - let state = &mut *LOCK.lock().unwrap(); - match state { - EstimateSmartFeeState::Ok => return Ok(()), - EstimateSmartFeeState::NotAvailable => return ERR!("estimatesmartfee not available"), - EstimateSmartFeeState::Idle => log!("Start wait_for_estimate_smart_fee"), - } - - let priv_key = random_secp256k1_secret(); - let (_ctx, coin) = qrc20_coin_from_privkey("QICK", priv_key); - let timeout = wait_until_sec(timeout); - let client = match coin.as_ref().rpc_client { - UtxoRpcClientEnum::Native(ref client) => client, - UtxoRpcClientEnum::Electrum(_) => panic!("Expected NativeClient"), - }; - while now_sec() < timeout { - if let Ok(res) = block_on_f01(client.estimate_smart_fee(&None, 1)) { - if res.errors.is_empty() { - *state = EstimateSmartFeeState::Ok; - return Ok(()); - } - } - thread::sleep(Duration::from_secs(1)); - } - - *state = EstimateSmartFeeState::NotAvailable; - ERR!("Waited too long for estimate_smart_fee to work") -} - -pub async fn enable_qrc20_native(mm: &MarketMakerIt, coin: &str) -> Json { - let swap_contract_address = - unsafe { QRC20_SWAP_CONTRACT_ADDRESS.expect("QRC20_SWAP_CONTRACT_ADDRESS must be set already") }; - - let native = mm - .rpc(&json! ({ - "userpass": mm.userpass, - "method": "enable", - "coin": coin, - "swap_contract_address": format!("{:#02x}", swap_contract_address), - "mm2": 1, - })) - .await - .unwrap(); - assert_eq!(native.0, StatusCode::OK, "'enable' failed: {}", native.1); - json::from_str(&native.1).unwrap() -} - -pub fn trade_base_rel((base, rel): (&str, &str)) { - /// Generate a wallet with the random private key and fill the wallet with Qtum (required by gas_fee) and specified in `ticker` coin. - fn generate_and_fill_priv_key(ticker: &str) -> Secp256k1Secret { - let timeout = 30; // timeout if test takes more than 30 seconds to run - - match ticker { - "QTUM" => { - //Segwit QTUM - wait_for_estimate_smart_fee(timeout).expect("!wait_for_estimate_smart_fee"); - let (_ctx, _coin, priv_key) = generate_segwit_qtum_coin_with_random_privkey("QTUM", 10.into(), Some(0)); - - priv_key - }, - "QICK" | "QORTY" => { - let priv_key = random_secp256k1_secret(); - let (_ctx, coin) = qrc20_coin_from_privkey(ticker, priv_key); - let my_address = coin.my_address().expect("!my_address"); - fill_address(&coin, &my_address, 10.into(), timeout); - fill_qrc20_address(&coin, 10.into(), timeout); - - priv_key - }, - "MYCOIN" | "MYCOIN1" => { - let priv_key = random_secp256k1_secret(); - let (_ctx, coin) = utxo_coin_from_privkey(ticker, priv_key); - let my_address = coin.my_address().expect("!my_address"); - fill_address(&coin, &my_address, 10.into(), timeout); - // also fill the Qtum - let (_ctx, coin) = qrc20_coin_from_privkey("QICK", priv_key); - let my_address = coin.my_address().expect("!my_address"); - fill_address(&coin, &my_address, 10.into(), timeout); - - priv_key - }, - "ADEXSLP" | "FORSLP" => Secp256k1Secret::from(get_prefilled_slp_privkey()), - "ETH" | "ERC20DEV" => { - let priv_key = random_secp256k1_secret(); - fill_eth_erc20_with_private_key(priv_key); - priv_key - }, - _ => panic!("Expected either QICK or QORTY or MYCOIN or MYCOIN1, found {}", ticker), - } - } - - let bob_priv_key = generate_and_fill_priv_key(base); - let alice_priv_key = generate_and_fill_priv_key(rel); - let alice_pubkey_str = hex::encode( - key_pair_from_secret(&alice_priv_key) - .expect("valid test key pair") - .public() - .to_vec(), - ); - - let mut envs = vec![]; - if SET_BURN_PUBKEY_TO_ALICE.get() { - envs.push(("TEST_BURN_ADDR_RAW_PUBKEY", alice_pubkey_str.as_str())); - } - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; - let coins = json! ([ - eth_dev_conf(), - erc20_dev_conf(&erc20_contract_checksum()), - qrc20_coin_conf_item("QICK"), - qrc20_coin_conf_item("QORTY"), - {"coin":"MYCOIN","asset":"MYCOIN","required_confirmations":0,"txversion":4,"overwintered":1,"txfee":1000,"protocol":{"type":"UTXO"}}, - {"coin":"MYCOIN1","asset":"MYCOIN1","required_confirmations":0,"txversion":4,"overwintered":1,"txfee":1000,"protocol":{"type":"UTXO"}}, - // TODO: check if we should fix protocol "type":"UTXO" to "QTUM" for this and other QTUM coin tests. - // Maybe we should use a different coin for "UTXO" protocol and make new tests for "QTUM" protocol - {"coin":"QTUM","asset":"QTUM","required_confirmations":0,"decimals":8,"pubtype":120,"p2shtype":110,"wiftype":128,"segwit":true,"txfee":0,"txfee_volatility_percent":0.1, "dust":72800, - "mm2":1,"network":"regtest","confpath":confpath,"protocol":{"type":"UTXO"},"bech32_hrp":"qcrt","address_format":{"format":"segwit"}}, - {"coin":"FORSLP","asset":"FORSLP","required_confirmations":0,"txversion":4,"overwintered":1,"txfee":1000,"protocol":{"type":"BCH","protocol_data":{"slp_prefix":"slptest"}}}, - {"coin":"ADEXSLP","protocol":{"type":"SLPTOKEN","protocol_data":{"decimals":8,"token_id":get_slp_token_id(),"platform":"FORSLP"}}} - ]); - let mut mm_bob = block_on(MarketMakerIt::start_with_envs( - json! ({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - envs.as_slice(), - )) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - block_on(mm_bob.wait_for_log(22., |log| log.contains(">>>>>>>>> DEX stats "))).unwrap(); - - let mut mm_alice = block_on(MarketMakerIt::start_with_envs( - json! ({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - envs.as_slice(), - )) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - block_on(mm_alice.wait_for_log(22., |log| log.contains(">>>>>>>>> DEX stats "))).unwrap(); - - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - log!("{:?}", block_on(enable_qrc20_native(&mm_bob, "QICK"))); - log!("{:?}", block_on(enable_qrc20_native(&mm_bob, "QORTY"))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "QTUM", &[], None))); - log!("{:?}", block_on(enable_native_bch(&mm_bob, "FORSLP", &[]))); - log!("{:?}", block_on(enable_native(&mm_bob, "ADEXSLP", &[], None))); - log!( - "{:?}", - block_on(enable_eth_coin( - &mm_bob, - "ETH", - &[GETH_RPC_URL], - &swap_contract, - None, - false - )) - ); - log!( - "{:?}", - block_on(enable_eth_coin( - &mm_bob, - "ERC20DEV", - &[GETH_RPC_URL], - &swap_contract, - None, - false - )) - ); - - log!("{:?}", block_on(enable_qrc20_native(&mm_alice, "QICK"))); - log!("{:?}", block_on(enable_qrc20_native(&mm_alice, "QORTY"))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "QTUM", &[], None))); - log!("{:?}", block_on(enable_native_bch(&mm_alice, "FORSLP", &[]))); - log!("{:?}", block_on(enable_native(&mm_alice, "ADEXSLP", &[], None))); - log!( - "{:?}", - block_on(enable_eth_coin( - &mm_alice, - "ETH", - &[GETH_RPC_URL], - &swap_contract, - None, - false - )) - ); - log!( - "{:?}", - block_on(enable_eth_coin( - &mm_alice, - "ERC20DEV", - &[GETH_RPC_URL], - &swap_contract, - None, - false - )) - ); - - let rc = block_on(mm_bob.rpc(&json! ({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": base, - "rel": rel, - "price": 1, - "volume": "3", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - thread::sleep(Duration::from_secs(1)); - - log!("Issue alice {}/{} buy request", base, rel); - let rc = block_on(mm_alice.rpc(&json! ({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": base, - "rel": rel, - "price": 1, - "volume": "2", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - let buy_json: Json = serde_json::from_str(&rc.1).unwrap(); - let uuid = buy_json["result"]["uuid"].as_str().unwrap().to_owned(); - - // ensure the swaps are started - block_on(mm_bob.wait_for_log(22., |log| { - log.contains(&format!("Entering the maker_swap_loop {base}/{rel}")) - })) - .unwrap(); - block_on(mm_alice.wait_for_log(22., |log| { - log.contains(&format!("Entering the taker_swap_loop {base}/{rel}")) - })) - .unwrap(); - - // ensure the swaps are finished - block_on(mm_bob.wait_for_log(600., |log| log.contains(&format!("[swap uuid={uuid}] Finished")))).unwrap(); - block_on(mm_alice.wait_for_log(600., |log| log.contains(&format!("[swap uuid={uuid}] Finished")))).unwrap(); - - log!("Checking alice/taker status.."); - block_on(check_my_swap_status( - &mm_alice, - &uuid, - "2".parse().unwrap(), - "2".parse().unwrap(), - )); - - log!("Checking bob/maker status.."); - block_on(check_my_swap_status( - &mm_bob, - &uuid, - "2".parse().unwrap(), - "2".parse().unwrap(), - )); - - log!("Checking alice status.."); - block_on(wait_check_stats_swap_status(&mm_alice, &uuid, 240)); - - log!("Checking bob status.."); - block_on(wait_check_stats_swap_status(&mm_bob, &uuid, 240)); - - log!("Checking alice recent swaps.."); - block_on(check_recent_swaps(&mm_alice, 1)); - log!("Checking bob recent swaps.."); - block_on(check_recent_swaps(&mm_bob, 1)); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -pub fn slp_supplied_node() -> MarketMakerIt { - let coins = json! ([ - {"coin":"FORSLP","asset":"FORSLP","required_confirmations":0,"txversion":4,"overwintered":1,"txfee":1000,"protocol":{"type":"BCH","protocol_data":{"slp_prefix":"slptest"}}}, - {"coin":"ADEXSLP","protocol":{"type":"SLPTOKEN","protocol_data":{"decimals":8,"token_id":get_slp_token_id(),"platform":"FORSLP"}}} - ]); - - let priv_key = get_prefilled_slp_privkey(); - MarketMakerIt::start( - json! ({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap() -} - -pub fn get_balance(mm: &MarketMakerIt, coin: &str) -> BalanceResponse { - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "method": "my_balance", - "coin": coin, - }))) - .unwrap(); - assert_eq!(rc.0, StatusCode::OK, "my_balance request failed {}", rc.1); - json::from_str(&rc.1).unwrap() -} - -pub fn utxo_burn_address() -> Address { - AddressBuilder::new( - UtxoAddressFormat::Standard, - ChecksumType::DSHA256, - NetworkAddressPrefixes { - p2pkh: [60].into(), - p2sh: AddressPrefix::default(), - }, - None, - ) - .as_pkh(AddressHashEnum::default_address_hash()) - .build() - .expect("valid address props") -} - -pub fn withdraw_max_and_send_v1(mm: &MarketMakerIt, coin: &str, to: &str) -> TransactionDetails { - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "method": "withdraw", - "coin": coin, - "max": true, - "to": to, - }))) - .unwrap(); - assert_eq!(rc.0, StatusCode::OK, "withdraw request failed {}", rc.1); - let tx_details: TransactionDetails = json::from_str(&rc.1).unwrap(); - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "method": "send_raw_transaction", - "tx_hex": tx_details.tx_hex, - "coin": coin, - }))) - .unwrap(); - assert_eq!(rc.0, StatusCode::OK, "send_raw_transaction request failed {}", rc.1); - - tx_details -} - -async fn get_current_gas_limit(web3: &Web3) { - match web3.eth().block(BlockId::Number(BlockNumber::Latest)).await { - Ok(Some(block)) => { - log!("Current gas limit: {}", block.gas_limit); - }, - Ok(None) => log!("Latest block information is not available."), - Err(e) => log!("Failed to fetch the latest block: {}", e), - } -} - -pub fn prepare_ibc_channels(container_id: &str) { - let exec = |args: &[&str]| { - Command::new("docker") - .args(["exec", container_id]) - .args(args) - .output() - .unwrap(); - }; - - exec(&["rly", "transact", "clients", "nucleus-atom", "--override"]); - // It takes a couple of seconds for nodes to get into the right state after updating clients. - // Wait for 5 just to make sure. - thread::sleep(Duration::from_secs(5)); - - exec(&["rly", "transact", "link", "nucleus-atom"]); -} - -pub fn wait_until_relayer_container_is_ready(container_id: &str) { - const Q_RESULT: &str = "0: nucleus-atom -> chns(βœ”) clnts(βœ”) conn(βœ”) (nucleus-testnet<>cosmoshub-testnet)"; - - let mut attempts = 0; - loop { - let mut docker = Command::new("docker"); - docker.arg("exec").arg(container_id).args(["rly", "paths", "list"]); - - log!("Running <<{docker:?}>>."); - - let output = docker.stderr(Stdio::inherit()).output().unwrap(); - let output = String::from_utf8(output.stdout).unwrap(); - let output = output.trim(); - - if output == Q_RESULT { - break; - } - attempts += 1; - - log!("Expected output {Q_RESULT}, received {output}."); - if attempts > 10 { - panic!("Reached max attempts for <<{:?}>>.", docker); - } else { - log!("Asking for relayer node status again.."); - } - - thread::sleep(Duration::from_secs(2)); - } -} - -pub fn init_geth_node() { - unsafe { - block_on(get_current_gas_limit(&GETH_WEB3)); - let gas_price = block_on(GETH_WEB3.eth().gas_price()).unwrap(); - log!("Current gas price: {:?}", gas_price); - let accounts = block_on(GETH_WEB3.eth().accounts()).unwrap(); - GETH_ACCOUNT = accounts[0]; - log!("GETH ACCOUNT {:?}", GETH_ACCOUNT); - - let tx_request_deploy_erc20 = TransactionRequest { - from: GETH_ACCOUNT, - to: None, - gas: None, - gas_price: None, - value: None, - data: Some(hex::decode(ERC20_TOKEN_BYTES).unwrap().into()), - nonce: None, - condition: None, - transaction_type: None, - access_list: None, - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - }; - - let deploy_erc20_tx_hash = block_on(GETH_WEB3.eth().send_transaction(tx_request_deploy_erc20)).unwrap(); - log!("Sent ERC20 deploy transaction {:?}", deploy_erc20_tx_hash); - - loop { - let deploy_tx_receipt = match block_on(GETH_WEB3.eth().transaction_receipt(deploy_erc20_tx_hash)) { - Ok(receipt) => receipt, - Err(_) => { - thread::sleep(Duration::from_millis(100)); - continue; - }, - }; - - if let Some(receipt) = deploy_tx_receipt { - GETH_ERC20_CONTRACT = receipt.contract_address.unwrap(); - log!("GETH_ERC20_CONTRACT {:?}", GETH_ERC20_CONTRACT); - break; - } - thread::sleep(Duration::from_millis(100)); - } - - let tx_request_deploy_swap_contract = TransactionRequest { - from: GETH_ACCOUNT, - to: None, - gas: None, - gas_price: None, - value: None, - data: Some(hex::decode(SWAP_CONTRACT_BYTES).unwrap().into()), - nonce: None, - condition: None, - transaction_type: None, - access_list: None, - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - }; - let deploy_swap_tx_hash = block_on(GETH_WEB3.eth().send_transaction(tx_request_deploy_swap_contract)).unwrap(); - log!("Sent deploy swap contract transaction {:?}", deploy_swap_tx_hash); - - loop { - let deploy_swap_tx_receipt = match block_on(GETH_WEB3.eth().transaction_receipt(deploy_swap_tx_hash)) { - Ok(receipt) => receipt, - Err(_) => { - thread::sleep(Duration::from_millis(100)); - continue; - }, - }; - - if let Some(receipt) = deploy_swap_tx_receipt { - GETH_SWAP_CONTRACT = receipt.contract_address.unwrap(); - log!("GETH_SWAP_CONTRACT {:?}", GETH_SWAP_CONTRACT); - break; - } - thread::sleep(Duration::from_millis(100)); - } - - let tx_request_deploy_maker_swap_contract_v2 = TransactionRequest { - from: GETH_ACCOUNT, - to: None, - gas: None, - gas_price: None, - value: None, - data: Some(hex::decode(MAKER_SWAP_V2_BYTES).unwrap().into()), - nonce: None, - condition: None, - transaction_type: None, - access_list: None, - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - }; - let deploy_maker_swap_v2_tx_hash = block_on( - GETH_WEB3 - .eth() - .send_transaction(tx_request_deploy_maker_swap_contract_v2), - ) - .unwrap(); - log!( - "Sent deploy maker swap v2 contract transaction {:?}", - deploy_maker_swap_v2_tx_hash - ); - - loop { - let deploy_maker_swap_v2_tx_receipt = - match block_on(GETH_WEB3.eth().transaction_receipt(deploy_maker_swap_v2_tx_hash)) { - Ok(receipt) => receipt, - Err(_) => { - thread::sleep(Duration::from_millis(100)); - continue; - }, - }; - - if let Some(receipt) = deploy_maker_swap_v2_tx_receipt { - GETH_MAKER_SWAP_V2 = receipt.contract_address.unwrap(); - log!( - "GETH_MAKER_SWAP_V2 contract address: {:?}, receipt.status: {:?}", - GETH_MAKER_SWAP_V2, - receipt.status - ); - break; - } - thread::sleep(Duration::from_millis(100)); - } - - let dex_fee_addr = Token::Address(GETH_ACCOUNT); - let params = ethabi::encode(&[dex_fee_addr]); - let taker_swap_v2_data = format!("{}{}", TAKER_SWAP_V2_BYTES, hex::encode(params)); - - let tx_request_deploy_taker_swap_contract_v2 = TransactionRequest { - from: GETH_ACCOUNT, - to: None, - gas: None, - gas_price: None, - value: None, - data: Some(hex::decode(taker_swap_v2_data).unwrap().into()), - nonce: None, - condition: None, - transaction_type: None, - access_list: None, - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - }; - let deploy_taker_swap_v2_tx_hash = block_on( - GETH_WEB3 - .eth() - .send_transaction(tx_request_deploy_taker_swap_contract_v2), - ) - .unwrap(); - log!( - "Sent deploy taker swap v2 contract transaction {:?}", - deploy_taker_swap_v2_tx_hash - ); - - loop { - let deploy_taker_swap_v2_tx_receipt = - match block_on(GETH_WEB3.eth().transaction_receipt(deploy_taker_swap_v2_tx_hash)) { - Ok(receipt) => receipt, - Err(_) => { - thread::sleep(Duration::from_millis(100)); - continue; - }, - }; - - if let Some(receipt) = deploy_taker_swap_v2_tx_receipt { - GETH_TAKER_SWAP_V2 = receipt.contract_address.unwrap(); - log!( - "GETH_TAKER_SWAP_V2 contract address: {:?}, receipt.status: {:?}", - GETH_TAKER_SWAP_V2, - receipt.status - ); - break; - } - thread::sleep(Duration::from_millis(100)); - } - - let tx_request_deploy_watchers_swap_contract = TransactionRequest { - from: GETH_ACCOUNT, - to: None, - gas: None, - gas_price: None, - value: None, - data: Some(hex::decode(WATCHERS_SWAP_CONTRACT_BYTES).unwrap().into()), - nonce: None, - condition: None, - transaction_type: None, - access_list: None, - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - }; - let deploy_watchers_swap_tx_hash = block_on( - GETH_WEB3 - .eth() - .send_transaction(tx_request_deploy_watchers_swap_contract), - ) - .unwrap(); - log!( - "Sent deploy watchers swap contract transaction {:?}", - deploy_watchers_swap_tx_hash - ); - - loop { - let deploy_watchers_swap_tx_receipt = - match block_on(GETH_WEB3.eth().transaction_receipt(deploy_watchers_swap_tx_hash)) { - Ok(receipt) => receipt, - Err(_) => { - thread::sleep(Duration::from_millis(100)); - continue; - }, - }; - - if let Some(receipt) = deploy_watchers_swap_tx_receipt { - GETH_WATCHERS_SWAP_CONTRACT = receipt.contract_address.unwrap(); - log!("GETH_WATCHERS_SWAP_CONTRACT {:?}", GETH_WATCHERS_SWAP_CONTRACT); - break; - } - thread::sleep(Duration::from_millis(100)); - } - - let tx_request_deploy_nft_maker_swap_v2_contract = TransactionRequest { - from: GETH_ACCOUNT, - to: None, - gas: None, - gas_price: None, - value: None, - data: Some(hex::decode(NFT_MAKER_SWAP_V2_BYTES).unwrap().into()), - nonce: None, - condition: None, - transaction_type: None, - access_list: None, - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - }; - let deploy_nft_maker_swap_v2_tx_hash = block_on( - GETH_WEB3 - .eth() - .send_transaction(tx_request_deploy_nft_maker_swap_v2_contract), - ) - .unwrap(); - log!( - "Sent deploy nft maker swap v2 contract transaction {:?}", - deploy_nft_maker_swap_v2_tx_hash - ); - - loop { - let deploy_nft_maker_swap_v2_tx_receipt = - match block_on(GETH_WEB3.eth().transaction_receipt(deploy_nft_maker_swap_v2_tx_hash)) { - Ok(receipt) => receipt, - Err(_) => { - thread::sleep(Duration::from_millis(100)); - continue; - }, - }; - - if let Some(receipt) = deploy_nft_maker_swap_v2_tx_receipt { - GETH_NFT_MAKER_SWAP_V2 = receipt.contract_address.unwrap(); - log!( - "GETH_NFT_MAKER_SWAP_V2 contact address: {:?}, receipt.status: {:?}", - GETH_NFT_MAKER_SWAP_V2, - receipt.status - ); - break; - } - thread::sleep(Duration::from_millis(100)); - } - - let tx_request_deploy_nft_maker_swap_v2_contract = TransactionRequest { - from: GETH_ACCOUNT, - to: None, - gas: None, - gas_price: None, - value: None, - data: Some(hex::decode(NFT_MAKER_SWAP_V2_BYTES).unwrap().into()), - nonce: None, - condition: None, - transaction_type: None, - access_list: None, - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - }; - let deploy_nft_maker_swap_v2_tx_hash = block_on( - GETH_WEB3 - .eth() - .send_transaction(tx_request_deploy_nft_maker_swap_v2_contract), - ) - .unwrap(); - log!( - "Sent deploy nft maker swap v2 contract transaction {:?}", - deploy_nft_maker_swap_v2_tx_hash - ); - - loop { - let deploy_nft_maker_swap_v2_tx_receipt = - match block_on(GETH_WEB3.eth().transaction_receipt(deploy_nft_maker_swap_v2_tx_hash)) { - Ok(receipt) => receipt, - Err(_) => { - thread::sleep(Duration::from_millis(100)); - continue; - }, - }; - - if let Some(receipt) = deploy_nft_maker_swap_v2_tx_receipt { - GETH_NFT_MAKER_SWAP_V2 = receipt.contract_address.unwrap(); - log!( - "GETH_NFT_MAKER_SWAP_V2 {:?}, receipt.status {:?}", - GETH_NFT_MAKER_SWAP_V2, - receipt.status - ); - break; - } - thread::sleep(Duration::from_millis(100)); - } - - let name = Token::String("MyNFT".into()); - let symbol = Token::String("MNFT".into()); - let params = ethabi::encode(&[name, symbol]); - let erc721_data = format!("{}{}", ERC721_TEST_TOKEN_BYTES, hex::encode(params)); - - let tx_request_deploy_erc721 = TransactionRequest { - from: GETH_ACCOUNT, - to: None, - gas: None, - gas_price: None, - value: None, - data: Some(hex::decode(erc721_data).unwrap().into()), - nonce: None, - condition: None, - transaction_type: None, - access_list: None, - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - }; - let deploy_erc721_tx_hash = block_on(GETH_WEB3.eth().send_transaction(tx_request_deploy_erc721)).unwrap(); - log!("Sent ERC721 deploy transaction {:?}", deploy_erc721_tx_hash); - - loop { - let deploy_erc721_tx_receipt = match block_on(GETH_WEB3.eth().transaction_receipt(deploy_erc721_tx_hash)) { - Ok(receipt) => receipt, - Err(_) => { - thread::sleep(Duration::from_millis(100)); - continue; - }, - }; - - if let Some(receipt) = deploy_erc721_tx_receipt { - GETH_ERC721_CONTRACT = receipt.contract_address.unwrap(); - log!("GETH_ERC721_CONTRACT {:?}", GETH_ERC721_CONTRACT); - break; - } - thread::sleep(Duration::from_millis(100)); - } - - let uri = Token::String("MyNFTUri".into()); - let params = ethabi::encode(&[uri]); - let erc1155_data = format!("{}{}", ERC1155_TEST_TOKEN_BYTES, hex::encode(params)); - - let tx_request_deploy_erc1155 = TransactionRequest { - from: GETH_ACCOUNT, - to: None, - gas: None, - gas_price: None, - value: None, - data: Some(hex::decode(erc1155_data).unwrap().into()), - nonce: None, - condition: None, - transaction_type: None, - access_list: None, - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - }; - let deploy_erc1155_tx_hash = block_on(GETH_WEB3.eth().send_transaction(tx_request_deploy_erc1155)).unwrap(); - log!("Sent ERC1155 deploy transaction {:?}", deploy_erc721_tx_hash); - - loop { - let deploy_erc1155_tx_receipt = match block_on(GETH_WEB3.eth().transaction_receipt(deploy_erc1155_tx_hash)) - { - Ok(receipt) => receipt, - Err(_) => { - thread::sleep(Duration::from_millis(100)); - continue; - }, - }; - - if let Some(receipt) = deploy_erc1155_tx_receipt { - GETH_ERC1155_CONTRACT = receipt.contract_address.unwrap(); - log!("GETH_ERC1155_CONTRACT {:?}", GETH_ERC1155_CONTRACT); - break; - } - thread::sleep(Duration::from_millis(100)); - } - - #[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] - { - SEPOLIA_ETOMIC_MAKER_NFT_SWAP_V2 = - EthAddress::from_str("0x9eb88cd58605d8fb9b14652d6152727f7e95fb4d").unwrap(); - SEPOLIA_ERC20_CONTRACT = EthAddress::from_str("0xF7b5F8E8555EF7A743f24D3E974E23A3C6cB6638").unwrap(); - SEPOLIA_TAKER_SWAP_V2 = EthAddress::from_str("0x3B19873b81a6B426c8B2323955215F7e89CfF33F").unwrap(); - // deploy tx https://sepolia.etherscan.io/tx/0x6f743d79ecb806f5899a6a801083e33eba9e6f10726af0873af9f39883db7f11 - SEPOLIA_MAKER_SWAP_V2 = EthAddress::from_str("0xf9000589c66Df3573645B59c10aa87594Edc318F").unwrap(); - } - let alice_passphrase = get_passphrase!(".env.client", "ALICE_PASSPHRASE").unwrap(); - let alice_keypair = key_pair_from_seed(&alice_passphrase).unwrap(); - let alice_eth_addr = addr_from_raw_pubkey(alice_keypair.public()).unwrap(); - // 100 ETH - fill_eth(alice_eth_addr, U256::from(10).pow(U256::from(20))); - - let bob_passphrase = get_passphrase!(".env.seed", "BOB_PASSPHRASE").unwrap(); - let bob_keypair = key_pair_from_seed(&bob_passphrase).unwrap(); - let bob_eth_addr = addr_from_raw_pubkey(bob_keypair.public()).unwrap(); - // 100 ETH - fill_eth(bob_eth_addr, U256::from(10).pow(U256::from(20))); - } -} diff --git a/mm2src/mm2_main/tests/docker_tests/docker_tests_inner.rs b/mm2src/mm2_main/tests/docker_tests/docker_tests_inner.rs index 4f7e40532e..9a4a43c8df 100644 --- a/mm2src/mm2_main/tests/docker_tests/docker_tests_inner.rs +++ b/mm2src/mm2_main/tests/docker_tests/docker_tests_inner.rs @@ -1,530 +1,55 @@ -use crate::docker_tests::docker_tests_common::{ - generate_utxo_coin_with_privkey, trade_base_rel, GETH_RPC_URL, MM_CTX, SET_BURN_PUBKEY_TO_ALICE, -}; -use crate::docker_tests::eth_docker_tests::{ - erc20_coin_with_random_privkey, erc20_contract_checksum, fill_eth_erc20_with_private_key, swap_contract, +// Docker Tests Inner - Cross-Chain Ordermatching Tests +// +// This module contains tests that require BOTH ETH and UTXO chains for ordermatching. +// These tests cannot be placed in either eth_inner_tests.rs or utxo_ordermatch_v1_tests.rs +// because they require cross-chain functionality. +// +// ETH-only tests have been extracted to: eth_inner_tests.rs +// UTXO-only ordermatching tests have been extracted to: utxo_ordermatch_v1_tests.rs +// +// Gated by: docker-tests-ordermatch (cross-chain ordermatching tests) + +use crate::docker_tests::helpers::env::random_secp256k1_secret; +use crate::docker_tests::helpers::eth::{ + erc20_contract_checksum, fill_eth_erc20_with_private_key, swap_contract_checksum, GETH_RPC_URL, }; +use crate::docker_tests::helpers::utxo::generate_utxo_coin_with_privkey; use crate::integration_tests_common::*; -use crate::{ - fill_address, generate_utxo_coin_with_random_privkey, random_secp256k1_secret, rmd160_from_priv, - utxo_coin_from_privkey, -}; -use bitcrypto::dhash160; -use chain::OutPoint; -use coins::utxo::rpc_clients::UnspentInfo; -use coins::utxo::{GetUtxoListOps, UtxoCommonOps}; -use coins::TxFeeDetails; -use coins::{ - ConfirmPaymentInput, FoundSwapTxSpend, MarketCoinOps, MmCoin, RefundPaymentArgs, SearchForSwapTxSpendInput, - SendPaymentArgs, SpendPaymentArgs, SwapOps, SwapTxTypeWithSecretHash, TransactionEnum, WithdrawRequest, -}; -use common::{block_on, block_on_f01, executor::Timer, get_utc_timestamp, now_sec, wait_until_sec}; +use common::block_on; use crypto::privkey::key_pair_from_seed; -use crypto::{CryptoCtx, DerivationPath, KeyPairPolicy}; -use http::StatusCode; -use mm2_libp2p::behaviours::atomicdex::MAX_TIME_GAP_FOR_CONNECTED_PEER; -use mm2_number::{BigDecimal, BigRational, MmNumber}; +use mm2_number::BigDecimal; use mm2_test_helpers::for_tests::{ - check_my_swap_status_amounts, disable_coin, disable_coin_err, enable_eth_coin, erc20_dev_conf, eth_dev_conf, - get_locked_amount, kmd_conf, max_maker_vol, mm_dump, mycoin1_conf, mycoin_conf, set_price, start_swaps, - task_enable_eth_with_tokens, wait_for_swap_contract_negotiation, wait_for_swap_negotiation_failure, MarketMakerIt, - Mm2TestConf, DEFAULT_RPC_PASSWORD, + best_orders_v2, best_orders_v2_by_number, enable_eth_coin, erc20_dev_conf, eth_dev_conf, mm_dump, my_balance, + mycoin1_conf, mycoin_conf, MarketMakerIt, Mm2TestConf, }; +use mm2_test_helpers::structs::BestOrdersResponse; use mm2_test_helpers::{get_passphrase, structs::*}; -use serde_json::Value as Json; -use std::collections::{HashMap, HashSet}; -use std::convert::TryInto; -use std::env; -use std::iter::FromIterator; -use std::str::FromStr; -use std::thread; -use std::time::Duration; - -#[test] -fn test_search_for_swap_tx_spend_native_was_refunded_taker() { - let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run - let (_ctx, coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); - let my_public_key = coin.my_public_key().unwrap(); - - let time_lock = now_sec() - 3600; - let taker_payment_args = SendPaymentArgs { - time_lock_duration: 0, - time_lock, - other_pubkey: my_public_key, - secret_hash: &[0; 20], - amount: 1u64.into(), - swap_contract_address: &None, - swap_unique_data: &[], - payment_instructions: &None, - watcher_reward: None, - wait_for_confirmation_until: 0, - }; - let tx = block_on(coin.send_taker_payment(taker_payment_args)).unwrap(); - - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: tx.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - let maker_refunds_payment_args = RefundPaymentArgs { - payment_tx: &tx.tx_hex(), - time_lock, - other_pubkey: my_public_key, - tx_type_with_secret_hash: SwapTxTypeWithSecretHash::TakerOrMakerPayment { - maker_secret_hash: &[0; 20], - }, - swap_contract_address: &None, - swap_unique_data: &[], - watcher_reward: false, - }; - let refund_tx = block_on(coin.send_maker_refunds_payment(maker_refunds_payment_args)).unwrap(); - - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: refund_tx.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - - let search_input = SearchForSwapTxSpendInput { - time_lock, - other_pub: coin.my_public_key().unwrap(), - secret_hash: &[0; 20], - tx: &tx.tx_hex(), - search_from_block: 0, - swap_contract_address: &None, - swap_unique_data: &[], - watcher_reward: false, - }; - let found = block_on(coin.search_for_swap_tx_spend_my(search_input)) - .unwrap() - .unwrap(); - assert_eq!(FoundSwapTxSpend::Refunded(refund_tx), found); -} - -#[test] -fn test_for_non_existent_tx_hex_utxo() { - // This test shouldn't wait till timeout! - let timeout = wait_until_sec(120); - let (_ctx, coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); - // bad transaction hex - let tx = hex::decode("0400008085202f8902bf17bf7d1daace52e08f732a6b8771743ca4b1cb765a187e72fd091a0aabfd52000000006a47304402203eaaa3c4da101240f80f9c5e9de716a22b1ec6d66080de6a0cca32011cd77223022040d9082b6242d6acf9a1a8e658779e1c655d708379862f235e8ba7b8ca4e69c6012102031d4256c4bc9f99ac88bf3dba21773132281f65f9bf23a59928bce08961e2f3ffffffffff023ca13c0e9e085dd13f481f193e8a3e8fd609020936e98b5587342d994f4d020000006b483045022100c0ba56adb8de923975052312467347d83238bd8d480ce66e8b709a7997373994022048507bcac921fdb2302fa5224ce86e41b7efc1a2e20ae63aa738dfa99b7be826012102031d4256c4bc9f99ac88bf3dba21773132281f65f9bf23a59928bce08961e2f3ffffffff0300e1f5050000000017a9141ee6d4c38a3c078eab87ad1a5e4b00f21259b10d87000000000000000016611400000000000000000000000000000000000000001b94d736000000001976a91405aab5342166f8594baf17a7d9bef5d56744332788ac2d08e35e000000000000000000000000000000").unwrap(); - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: tx, - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - let actual = block_on_f01(coin.wait_for_confirmations(confirm_payment_input)) - .err() - .unwrap(); - assert!(actual.contains( - "Tx d342ff9da528a2e262bddf2b6f9a27d1beb7aeb03f0fc8d9eac2987266447e44 was not found on chain after 10 tries" - )); -} - -#[test] -fn test_search_for_swap_tx_spend_native_was_refunded_maker() { - let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run - let (_ctx, coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); - let my_public_key = coin.my_public_key().unwrap(); - - let time_lock = now_sec() - 3600; - let maker_payment_args = SendPaymentArgs { - time_lock_duration: 0, - time_lock, - other_pubkey: my_public_key, - secret_hash: &[0; 20], - amount: 1u64.into(), - swap_contract_address: &None, - swap_unique_data: &[], - payment_instructions: &None, - watcher_reward: None, - wait_for_confirmation_until: 0, - }; - let tx = block_on(coin.send_maker_payment(maker_payment_args)).unwrap(); - - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: tx.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - let maker_refunds_payment_args = RefundPaymentArgs { - payment_tx: &tx.tx_hex(), - time_lock, - other_pubkey: my_public_key, - tx_type_with_secret_hash: SwapTxTypeWithSecretHash::TakerOrMakerPayment { - maker_secret_hash: &[0; 20], - }, - swap_contract_address: &None, - swap_unique_data: &[], - watcher_reward: false, - }; - let refund_tx = block_on(coin.send_maker_refunds_payment(maker_refunds_payment_args)).unwrap(); - - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: refund_tx.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - - let search_input = SearchForSwapTxSpendInput { - time_lock, - other_pub: coin.my_public_key().unwrap(), - secret_hash: &[0; 20], - tx: &tx.tx_hex(), - search_from_block: 0, - swap_contract_address: &None, - swap_unique_data: &[], - watcher_reward: false, - }; - let found = block_on(coin.search_for_swap_tx_spend_my(search_input)) - .unwrap() - .unwrap(); - assert_eq!(FoundSwapTxSpend::Refunded(refund_tx), found); -} - -#[test] -fn test_search_for_taker_swap_tx_spend_native_was_spent_by_maker() { - let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run - let (_ctx, coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); - let secret = [0; 32]; - let my_pubkey = coin.my_public_key().unwrap(); - - let secret_hash = dhash160(&secret); - let time_lock = now_sec() - 3600; - let taker_payment_args = SendPaymentArgs { - time_lock_duration: 0, - time_lock, - other_pubkey: my_pubkey, - secret_hash: secret_hash.as_slice(), - amount: 1u64.into(), - swap_contract_address: &None, - swap_unique_data: &[], - payment_instructions: &None, - watcher_reward: None, - wait_for_confirmation_until: 0, - }; - let tx = block_on(coin.send_taker_payment(taker_payment_args)).unwrap(); - - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: tx.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - let maker_spends_payment_args = SpendPaymentArgs { - other_payment_tx: &tx.tx_hex(), - time_lock, - other_pubkey: my_pubkey, - secret: &secret, - secret_hash: secret_hash.as_slice(), - swap_contract_address: &None, - swap_unique_data: &[], - watcher_reward: false, - }; - let spend_tx = block_on(coin.send_maker_spends_taker_payment(maker_spends_payment_args)).unwrap(); - - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: spend_tx.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - - let search_input = SearchForSwapTxSpendInput { - time_lock, - other_pub: coin.my_public_key().unwrap(), - secret_hash: &*dhash160(&secret), - tx: &tx.tx_hex(), - search_from_block: 0, - swap_contract_address: &None, - swap_unique_data: &[], - watcher_reward: false, - }; - let found = block_on(coin.search_for_swap_tx_spend_my(search_input)) - .unwrap() - .unwrap(); - assert_eq!(FoundSwapTxSpend::Spent(spend_tx), found); -} - -#[test] -fn test_search_for_maker_swap_tx_spend_native_was_spent_by_taker() { - let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run - let (_ctx, coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); - let secret = [0; 32]; - let my_pubkey = coin.my_public_key().unwrap(); - - let time_lock = now_sec() - 3600; - let secret_hash = dhash160(&secret); - let maker_payment_args = SendPaymentArgs { - time_lock_duration: 0, - time_lock, - other_pubkey: my_pubkey, - secret_hash: secret_hash.as_slice(), - amount: 1u64.into(), - swap_contract_address: &None, - swap_unique_data: &[], - payment_instructions: &None, - watcher_reward: None, - wait_for_confirmation_until: 0, - }; - let tx = block_on(coin.send_maker_payment(maker_payment_args)).unwrap(); - - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: tx.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - let taker_spends_payment_args = SpendPaymentArgs { - other_payment_tx: &tx.tx_hex(), - time_lock, - other_pubkey: my_pubkey, - secret: &secret, - secret_hash: secret_hash.as_slice(), - swap_contract_address: &None, - swap_unique_data: &[], - watcher_reward: false, - }; - let spend_tx = block_on(coin.send_taker_spends_maker_payment(taker_spends_payment_args)).unwrap(); - - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: spend_tx.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - - let search_input = SearchForSwapTxSpendInput { - time_lock, - other_pub: coin.my_public_key().unwrap(), - secret_hash: &*dhash160(&secret), - tx: &tx.tx_hex(), - search_from_block: 0, - swap_contract_address: &None, - swap_unique_data: &[], - watcher_reward: false, - }; - let found = block_on(coin.search_for_swap_tx_spend_my(search_input)) - .unwrap() - .unwrap(); - assert_eq!(FoundSwapTxSpend::Spent(spend_tx), found); -} - -#[test] -fn test_one_hundred_maker_payments_in_a_row_native() { - let timeout = 30; // timeout if test takes more than 30 seconds to run - let (_ctx, coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); - let secret = [0; 32]; - let my_pubkey = coin.my_public_key().unwrap(); - - let time_lock = now_sec() - 3600; - let mut unspents = vec![]; - let mut sent_tx = vec![]; - for i in 0..100 { - let maker_payment_args = SendPaymentArgs { - time_lock_duration: 0, - time_lock: time_lock + i, - other_pubkey: my_pubkey, - secret_hash: &*dhash160(&secret), - amount: 1.into(), - swap_contract_address: &coin.swap_contract_address(), - swap_unique_data: &[], - payment_instructions: &None, - watcher_reward: None, - wait_for_confirmation_until: 0, - }; - let tx = block_on(coin.send_maker_payment(maker_payment_args)).unwrap(); - if let TransactionEnum::UtxoTx(tx) = tx { - unspents.push(UnspentInfo { - outpoint: OutPoint { - hash: tx.hash(), - index: 2, - }, - value: tx.outputs[2].value, - height: None, - script: coin - .script_for_address(&block_on(coin.as_ref().derivation_method.unwrap_single_addr())) - .unwrap(), - }); - sent_tx.push(tx); - } - } - - let recently_sent = block_on(coin.as_ref().recently_spent_outpoints.lock()); +use serde_json::json; - unspents = recently_sent - .replace_spent_outputs_with_cache(unspents.into_iter().collect()) - .into_iter() - .collect(); - - let last_tx = sent_tx.last().unwrap(); - let expected_unspent = UnspentInfo { - outpoint: OutPoint { - hash: last_tx.hash(), - index: 2, - }, - value: last_tx.outputs[2].value, - height: None, - script: last_tx.outputs[2].script_pubkey.clone().into(), - }; - assert_eq!(vec![expected_unspent], unspents); -} +// ============================================================================= +// Cross-Chain Matching Tests (UTXO + ETH) +// These tests verify order matching between different chain types +// ============================================================================= #[test] -// https://github.com/KomodoPlatform/atomicDEX-API/issues/554 -fn order_should_be_cancelled_when_entire_balance_is_withdrawn() { - let (_ctx, _, priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - - let mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "myipaddr": env::var("BOB_TRADE_IP") .ok(), - "rpcip": env::var("BOB_TRADE_IP") .ok(), - "canbind": env::var("BOB_TRADE_PORT") .ok().map (|s| s.parse::().unwrap()), - "passphrase": format!("0x{}", hex::encode(priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": "999", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - let json: Json = serde_json::from_str(&rc.1).unwrap(); - let bob_uuid = json["result"]["uuid"].as_str().unwrap().to_owned(); - - log!("Get MYCOIN/MYCOIN1 orderbook"); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("orderbook {:?}", bob_orderbook); - let asks = bob_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 1, "MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); - - let withdraw = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "withdraw", - "coin": "MYCOIN", - "max": true, - "to": "R9imXLs1hEcU9KbFDQq2hJEEJ1P5UoekaF", - }))) - .unwrap(); - assert!(withdraw.0.is_success(), "!withdraw: {}", withdraw.1); - - let withdraw: Json = serde_json::from_str(&withdraw.1).unwrap(); - - let send_raw = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "send_raw_transaction", - "coin": "MYCOIN", - "tx_hex": withdraw["tx_hex"], - }))) - .unwrap(); - assert!(send_raw.0.is_success(), "!send_raw: {}", send_raw.1); - - thread::sleep(Duration::from_secs(32)); - - log!("Get MYCOIN/MYCOIN1 orderbook"); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("orderbook {}", serde_json::to_string(&bob_orderbook).unwrap()); - let asks = bob_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 0, "MYCOIN/MYCOIN1 orderbook must have exactly 0 asks"); - - log!("Get my orders"); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "my_orders", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!my_orders: {}", rc.1); - let orders: Json = serde_json::from_str(&rc.1).unwrap(); - log!("my_orders {}", serde_json::to_string(&orders).unwrap()); - assert!( - orders["result"]["maker_orders"].as_object().unwrap().is_empty(), - "maker_orders must be empty" - ); +// https://github.com/KomodoPlatform/atomicDEX-API/issues/1074 +fn test_match_utxo_with_eth_taker_sell() { + let alice_passphrase = get_passphrase!(".env.client", "ALICE_PASSPHRASE").unwrap(); + let bob_passphrase = get_passphrase!(".env.seed", "BOB_PASSPHRASE").unwrap(); + let alice_priv_key = key_pair_from_seed(&alice_passphrase).unwrap().private().secret; + let bob_priv_key = key_pair_from_seed(&bob_passphrase).unwrap().private().secret; - let rmd160 = rmd160_from_priv(priv_key); - let order_path = mm_bob.folder.join(format!( - "DB/{}/ORDERS/MY/MAKER/{}.json", - hex::encode(rmd160.take()), - bob_uuid, - )); - log!("Order path {}", order_path.display()); - assert!(!order_path.exists()); - block_on(mm_bob.stop()).unwrap(); -} + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), alice_priv_key); + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), bob_priv_key); -#[test] -fn order_should_be_updated_when_balance_is_decreased_alice_subscribes_after_update() { - let (_ctx, _, priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let coins = json!([mycoin_conf(1000), eth_dev_conf()]); - let mm_bob = MarketMakerIt::start( + let mut mm_bob = MarketMakerIt::start( json!({ "gui": "nogui", "netid": 9000, "dht": "on", // Enable DHT without delay. - "myipaddr": env::var("BOB_TRADE_IP") .ok(), - "rpcip": env::var("BOB_TRADE_IP") .ok(), - "canbind": env::var("BOB_TRADE_PORT") .ok().map (|s| s.parse::().unwrap()), - "passphrase": format!("0x{}", hex::encode(priv_key)), + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), "coins": coins, "rpc_password": "pass", "i_am_seed": true, @@ -536,12 +61,12 @@ fn order_should_be_updated_when_balance_is_decreased_alice_subscribes_after_upda .unwrap(); let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - let mm_alice = MarketMakerIt::start( + let mut mm_alice = MarketMakerIt::start( json!({ "gui": "nogui", "netid": 9000, "dht": "on", // Enable DHT without delay. - "passphrase": "alice passphrase", + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), "coins": coins, "rpc_password": "pass", "seednodes": vec![format!("{}", mm_bob.ip)], @@ -553,112 +78,55 @@ fn order_should_be_updated_when_balance_is_decreased_alice_subscribes_after_upda let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + block_on(enable_native(&mm_bob, "ETH", &[GETH_RPC_URL], None)); + block_on(enable_native(&mm_alice, "ETH", &[GETH_RPC_URL], None)); let rc = block_on(mm_bob.rpc(&json!({ "userpass": mm_bob.userpass, "method": "setprice", "base": "MYCOIN", - "rel": "MYCOIN1", + "rel": "ETH", "price": 1, - "volume": "999", + "volume": "0.0001", }))) .unwrap(); assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - log!("Get MYCOIN/MYCOIN1 orderbook"); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("orderbook {:?}", bob_orderbook); - let asks = bob_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 1, "MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); - - let withdraw = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "withdraw", - "coin": "MYCOIN", - "amount": "499.99999481", - "to": "R9imXLs1hEcU9KbFDQq2hJEEJ1P5UoekaF", - }))) - .unwrap(); - assert!(withdraw.0.is_success(), "!withdraw: {}", withdraw.1); - - let withdraw: Json = serde_json::from_str(&withdraw.1).unwrap(); - - let send_raw = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "send_raw_transaction", - "coin": "MYCOIN", - "tx_hex": withdraw["tx_hex"], - }))) - .unwrap(); - assert!(send_raw.0.is_success(), "!send_raw: {}", send_raw.1); - - thread::sleep(Duration::from_secs(32)); - - log!("Get MYCOIN/MYCOIN1 orderbook on Bob side"); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("orderbook {}", serde_json::to_string(&bob_orderbook).unwrap()); - let asks = bob_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 1, "Bob MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); - - let order_volume = asks[0]["maxvolume"].as_str().unwrap(); - assert_eq!("500", order_volume); // 1000.0 - (499.99999481 + 0.00000274 txfee) = (500.0 + 0.00000274 txfee) - - log!("Get MYCOIN/MYCOIN1 orderbook on Alice side"); let rc = block_on(mm_alice.rpc(&json!({ "userpass": mm_alice.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", + "method": "sell", + "base": "ETH", + "rel": "MYCOIN", + "price": 1, + "volume": "0.0001", }))) .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let alice_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("orderbook {}", serde_json::to_string(&alice_orderbook).unwrap()); - let asks = alice_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 1, "Alice MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); + assert!(rc.0.is_success(), "!sell: {}", rc.1); - let order_volume = asks[0]["maxvolume"].as_str().unwrap(); - assert_eq!("500", order_volume); + block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/ETH"))).unwrap(); + block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/ETH"))).unwrap(); block_on(mm_bob.stop()).unwrap(); block_on(mm_alice.stop()).unwrap(); } #[test] -fn order_should_be_updated_when_balance_is_decreased_alice_subscribes_before_update() { - let (_ctx, _, priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mm_bob = MarketMakerIt::start( +// https://github.com/KomodoPlatform/atomicDEX-API/issues/1074 +fn test_match_utxo_with_eth_taker_buy() { + let alice_passphrase = get_passphrase!(".env.client", "ALICE_PASSPHRASE").unwrap(); + let bob_passphrase = get_passphrase!(".env.seed", "BOB_PASSPHRASE").unwrap(); + let alice_priv_key = key_pair_from_seed(&alice_passphrase).unwrap().private().secret; + let bob_priv_key = key_pair_from_seed(&bob_passphrase).unwrap().private().secret; + + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), alice_priv_key); + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), bob_priv_key); + let coins = json!([mycoin_conf(1000), eth_dev_conf()]); + let mut mm_bob = MarketMakerIt::start( json!({ "gui": "nogui", "netid": 9000, "dht": "on", // Enable DHT without delay. - "myipaddr": env::var("BOB_TRADE_IP") .ok(), - "rpcip": env::var("BOB_TRADE_IP") .ok(), - "canbind": env::var("BOB_TRADE_PORT") .ok().map (|s| s.parse::().unwrap()), - "passphrase": format!("0x{}", hex::encode(priv_key)), + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), "coins": coins, "rpc_password": "pass", "i_am_seed": true, @@ -670,12 +138,12 @@ fn order_should_be_updated_when_balance_is_decreased_alice_subscribes_before_upd .unwrap(); let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - let mm_alice = MarketMakerIt::start( + let mut mm_alice = MarketMakerIt::start( json!({ "gui": "nogui", "netid": 9000, "dht": "on", // Enable DHT without delay. - "passphrase": "alice passphrase", + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), "coins": coins, "rpc_password": "pass", "seednodes": vec![format!("{}", mm_bob.ip)], @@ -687,4099 +155,120 @@ fn order_should_be_updated_when_balance_is_decreased_alice_subscribes_before_upd let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + block_on(enable_native(&mm_bob, "ETH", &[GETH_RPC_URL], None)); + + block_on(enable_native(&mm_alice, "ETH", &[GETH_RPC_URL], None)); let rc = block_on(mm_bob.rpc(&json!({ "userpass": mm_bob.userpass, "method": "setprice", "base": "MYCOIN", - "rel": "MYCOIN1", + "rel": "ETH", "price": 1, - "volume": "999", + "volume": "0.0001", }))) .unwrap(); assert!(rc.0.is_success(), "!setprice: {}", rc.1); - log!("Get MYCOIN/MYCOIN1 orderbook"); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("orderbook {:?}", bob_orderbook); - let asks = bob_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 1, "Bob MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); - - thread::sleep(Duration::from_secs(2)); - log!("Get MYCOIN/MYCOIN1 orderbook on Alice side"); let rc = block_on(mm_alice.rpc(&json!({ "userpass": mm_alice.userpass, - "method": "orderbook", + "method": "buy", "base": "MYCOIN", - "rel": "MYCOIN1", + "rel": "ETH", + "price": 1, + "volume": "0.0001", }))) .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + assert!(rc.0.is_success(), "!buy: {}", rc.1); - let alice_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("orderbook {}", serde_json::to_string(&alice_orderbook).unwrap()); - let asks = alice_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 1, "Alice MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); + block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/ETH"))).unwrap(); + block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/ETH"))).unwrap(); - let withdraw = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "withdraw", - "coin": "MYCOIN", - "amount": "499.99999481", - "to": "R9imXLs1hEcU9KbFDQq2hJEEJ1P5UoekaF", - }))) - .unwrap(); - assert!(withdraw.0.is_success(), "!withdraw: {}", withdraw.1); + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} - let withdraw: Json = serde_json::from_str(&withdraw.1).unwrap(); +// ============================================================================= +// Cross-Chain Volume Validation Tests +// These tests check order volume constraints across ETH and UTXO coins +// ============================================================================= - let send_raw = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "send_raw_transaction", - "coin": "MYCOIN", - "tx_hex": withdraw["tx_hex"], +fn check_too_low_volume_order_creation_fails(mm: &MarketMakerIt, base: &str, rel: &str) { + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "setprice", + "base": base, + "rel": rel, + "price": "1", + "volume": "0.00000099", + "cancel_previous": false, }))) .unwrap(); - assert!(send_raw.0.is_success(), "!send_raw: {}", send_raw.1); - - thread::sleep(Duration::from_secs(32)); + assert!(!rc.0.is_success(), "setprice success, but should be error {}", rc.1); - log!("Get MYCOIN/MYCOIN1 orderbook on Bob side"); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "setprice", + "base": base, + "rel": rel, + "price": "0.00000000000000000099", + "volume": "1", + "cancel_previous": false, }))) .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("orderbook {}", serde_json::to_string(&bob_orderbook).unwrap()); - let asks = bob_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 1, "Bob MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); - - let order_volume = asks[0]["maxvolume"].as_str().unwrap(); - assert_eq!("500", order_volume); // 1000.0 - (499.99999481 + 0.00000245 txfee) = (500.0 + 0.00000274 txfee) - - log!("Get MYCOIN/MYCOIN1 orderbook on Alice side"); - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let alice_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("orderbook {}", serde_json::to_string(&alice_orderbook).unwrap()); - let asks = alice_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 1, "Alice MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); - - let order_volume = asks[0]["maxvolume"].as_str().unwrap(); - assert_eq!("500", order_volume); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -fn test_order_should_be_updated_when_matched_partially() { - let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 2000.into()); - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 2000.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let mut mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": "1000", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": "500", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - - block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - - log!("Get MYCOIN/MYCOIN1 orderbook on Bob side"); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("orderbook {}", serde_json::to_string(&bob_orderbook).unwrap()); - let asks = bob_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 1, "Bob MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); - - let order_volume = asks[0]["maxvolume"].as_str().unwrap(); - assert_eq!("500", order_volume); - - log!("Get MYCOIN/MYCOIN1 orderbook on Alice side"); - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let alice_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("orderbook {}", serde_json::to_string(&alice_orderbook).unwrap()); - let asks = alice_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 1, "Alice MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -// https://github.com/KomodoPlatform/atomicDEX-API/issues/471 -fn test_match_and_trade_setprice_max() { - let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 2000.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let mut mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "max": true, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - let json: Json = serde_json::from_str(&rc.1).unwrap(); - let bob_uuid = json["result"]["uuid"].as_str().unwrap().to_owned(); - - log!("Get MYCOIN/MYCOIN1 orderbook"); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("orderbook {:?}", bob_orderbook); - let asks = bob_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 1, "MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); - assert_eq!(asks[0]["maxvolume"], Json::from("999.99999726")); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": "999.99999", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - - block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - - thread::sleep(Duration::from_secs(3)); - - let rmd160 = rmd160_from_priv(bob_priv_key); - let order_path = mm_bob.folder.join(format!( - "DB/{}/ORDERS/MY/MAKER/{}.json", - hex::encode(rmd160.take()), - bob_uuid, - )); - log!("Order path {}", order_path.display()); - assert!(!order_path.exists()); - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -// https://github.com/KomodoPlatform/atomicDEX-API/issues/888 -fn test_max_taker_vol_swap() { - let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 50.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut mm_bob = block_on(MarketMakerIt::start_with_envs( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - &[("MYCOIN_FEE_DISCOUNT", "")], - )) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - block_on(mm_bob.wait_for_log(22., |log| log.contains(">>>>>>>>> DEX stats "))).unwrap(); - - let mut mm_alice = block_on(MarketMakerIt::start_with_envs( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - &[("MYCOIN_FEE_DISCOUNT", "")], - )) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - block_on(mm_alice.wait_for_log(22., |log| log.contains(">>>>>>>>> DEX stats "))).unwrap(); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - let price = MmNumber::from((100, 1620)); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": price, - "max": true, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "orderbook", - "base": "MYCOIN1", - "rel": "MYCOIN", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - log!("{}", rc.1); - thread::sleep(Duration::from_secs(3)); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "max_taker_vol", - "coin": "MYCOIN1", - "trade_with": "MYCOIN", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!max_taker_vol: {}", rc.1); - let vol: MaxTakerVolResponse = serde_json::from_str(&rc.1).unwrap(); - let expected_vol = MmNumber::from((1294999865579, 25930000000)); - - let actual_vol = MmNumber::from(vol.result.clone()); - log!("actual vol {}", actual_vol.to_decimal()); - log!("expected vol {}", expected_vol.to_decimal()); - - assert_eq!(expected_vol, actual_vol); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "sell", - "base": "MYCOIN1", - "rel": "MYCOIN", - "price": "16", - "volume": vol.result, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!sell: {}", rc.1); - let sell_res: BuyOrSellRpcResult = serde_json::from_str(&rc.1).unwrap(); - - block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - - thread::sleep(Duration::from_secs(3)); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "my_swap_status", - "params": { - "uuid": sell_res.result.uuid - } - }))) - .unwrap(); - assert!(rc.0.is_success(), "!my_swap_status: {}", rc.1); - - let status_response: Json = serde_json::from_str(&rc.1).unwrap(); - let events_array = status_response["result"]["events"].as_array().unwrap(); - let first_event_type = events_array[0]["event"]["type"].as_str().unwrap(); - assert_eq!("Started", first_event_type); - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -fn test_buy_when_coins_locked_by_other_swap() { - let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 2.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let mut mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "max": true, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - // the result of equation x + x / 777 + 0.00000274 dexfee_txfee + 0.00000245 payment_txfee = 1 - "volume": { - "numer":"77699596737", - "denom":"77800000000" - }, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - - block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - // TODO when buy call is made immediately swap might be not put into swap ctx yet so locked - // amount returns 0 - thread::sleep(Duration::from_secs(6)); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - // it is slightly more than previous volume so it should fail - // because the total sum of used funds will be slightly more than available 2 - "volume": { - "numer":"77699599999", // increase volume +0.00000001 - "denom":"77800000000" - }, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "buy success, but should fail: {}", rc.1); - assert!(rc.1.contains("Not enough MYCOIN1 for swap"), "{}", rc.1); - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -fn test_sell_when_coins_locked_by_other_swap() { - let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 2.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let mut mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "max": true, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "sell", - "base": "MYCOIN1", - "rel": "MYCOIN", - "price": 1, - // the result of equation x + x / 777 + 0.00000245 + 0.00000274 = 1 - "volume": { - "numer":"77699596737", - "denom":"77800000000" - }, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!sell: {}", rc.1); - - block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - // TODO when sell call is made immediately swap might be not put into swap ctx yet so locked - // amount returns 0 - // NOTE: in this test sometimes Alice has time to send only the taker fee, sometimes can send even the payment tx too - thread::sleep(Duration::from_secs(6)); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "sell", - "base": "MYCOIN1", - "rel": "MYCOIN", - "price": 1, - // it is slightly more than previous volume so it should fail - // because the total sum of used funds will be slightly more than available 2 - "volume": { - "numer":"77699599999", // ensure volume > 1.00000000 - "denom":"77800000000" - }, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "sell success, but should fail: {}", rc.1); - assert!(rc.1.contains("Not enough MYCOIN1 for swap"), "{}", rc.1); - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -fn test_buy_max() { - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - // the result of equation x + x / 777 + 0.00000274 dexfee_txfee + 0.00000245 payment_txfee = 1 - // (btw no need to add refund txfee - it's taken from the spend amount for utxo taker) - "volume": { - "numer":"77699596737", - "denom":"77800000000" - }, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - // it is slightly more than previous volume so it should fail - "volume": { - "numer":"77699596738", - "denom":"77800000000" - }, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "buy success, but should fail: {}", rc.1); - // assert! (rc.1.contains("MYCOIN1 balance 1 is too low")); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -fn test_maker_trade_preimage() { - let priv_key = random_secp256k1_secret(); - - let (_ctx, mycoin) = utxo_coin_from_privkey("MYCOIN", priv_key); - let my_address = mycoin.my_address().expect("!my_address"); - fill_address(&mycoin, &my_address, 10.into(), 30); - - let (_ctx, mycoin1) = utxo_coin_from_privkey("MYCOIN1", priv_key); - let my_address = mycoin1.my_address().expect("!my_address"); - fill_address(&mycoin1, &my_address, 20.into(), 30); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(2000)]); - let mm = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_dump_log, _dump_dashboard) = mm_dump(&mm.log_path); - - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "trade_preimage", - "params": { - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "setprice", - "price": 1, - "max": true, - }, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!trade_preimage: {}", rc.1); - let base_coin_fee = TradeFeeForTest::new("MYCOIN", "0.00000274", false); // txfee from get_sender_trade_fee - let rel_coin_fee = TradeFeeForTest::new("MYCOIN1", "0.00000992", true); - let volume = MmNumber::from("9.99999726"); // 1.0 - 0.00000274 from calc_max_maker_vol - - let my_coin_total = TotalTradeFeeForTest::new("MYCOIN", "0.00000274", "0.00000274"); - let my_coin1_total = TotalTradeFeeForTest::new("MYCOIN1", "0.00000992", "0"); - - let expected = TradePreimageResult::MakerPreimage(MakerPreimage { - base_coin_fee, - rel_coin_fee, - volume: Some(volume.to_decimal()), - volume_rat: Some(volume.to_ratio()), - volume_fraction: Some(volume.to_fraction()), - total_fees: vec![my_coin_total, my_coin1_total], - }); - - let mut actual: RpcSuccessResponse = serde_json::from_str(&rc.1).unwrap(); - actual.result.sort_total_fees(); - assert_eq!(expected, actual.result); - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "trade_preimage", - "params": { - "base": "MYCOIN1", - "rel": "MYCOIN", - "swap_method": "setprice", - "price": 1, - "max": true, - }, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!trade_preimage: {}", rc.1); - let mut actual: RpcSuccessResponse = serde_json::from_str(&rc.1).unwrap(); - actual.result.sort_total_fees(); - - let base_coin_fee = TradeFeeForTest::new("MYCOIN1", "0.00000548", false); - let rel_coin_fee = TradeFeeForTest::new("MYCOIN", "0.00000496", true); - let volume = MmNumber::from("19.99999452"); - - let my_coin_total = TotalTradeFeeForTest::new("MYCOIN", "0.00000496", "0"); - let my_coin1_total = TotalTradeFeeForTest::new("MYCOIN1", "0.00000548", "0.00000548"); - let expected = TradePreimageResult::MakerPreimage(MakerPreimage { - base_coin_fee, - rel_coin_fee, - volume: Some(volume.to_decimal()), - volume_rat: Some(volume.to_ratio()), - volume_fraction: Some(volume.to_fraction()), - total_fees: vec![my_coin_total, my_coin1_total], - }); - - actual.result.sort_total_fees(); - assert_eq!(expected, actual.result); - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "trade_preimage", - "params": { - "base": "MYCOIN1", - "rel": "MYCOIN", - "swap_method": "setprice", - "price": 1, - "volume": "19.99999109", // actually try max value (balance - txfee = 20.0 - 0.00000823) - }, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!trade_preimage: {}", rc.1); - let mut actual: RpcSuccessResponse = serde_json::from_str(&rc.1).unwrap(); - actual.result.sort_total_fees(); - - let base_coin_fee = TradeFeeForTest::new("MYCOIN1", "0.00000891", false); // txfee updated for calculated max volume (not 616) - let rel_coin_fee = TradeFeeForTest::new("MYCOIN", "0.00000496", true); - - let total_my_coin = TotalTradeFeeForTest::new("MYCOIN", "0.00000496", "0"); - let total_my_coin1 = TotalTradeFeeForTest::new("MYCOIN1", "0.00000891", "0.00000891"); - - let expected = TradePreimageResult::MakerPreimage(MakerPreimage { - base_coin_fee, - rel_coin_fee, - volume: None, - volume_rat: None, - volume_fraction: None, - total_fees: vec![total_my_coin, total_my_coin1], - }); - - actual.result.sort_total_fees(); - assert_eq!(expected, actual.result); -} - -#[test] -fn test_taker_trade_preimage() { - let priv_key = random_secp256k1_secret(); - - let (_ctx, mycoin) = utxo_coin_from_privkey("MYCOIN", priv_key); - let my_address = mycoin.my_address().expect("!my_address"); - fill_address(&mycoin, &my_address, 10.into(), 30); - - let (_ctx, mycoin1) = utxo_coin_from_privkey("MYCOIN1", priv_key); - let my_address = mycoin1.my_address().expect("!my_address"); - fill_address(&mycoin1, &my_address, 20.into(), 30); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(2000)]); - let mm = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_dump_log, _dump_dashboard) = mm_dump(&mm.log_path); - - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - - // `max` field is not supported for `buy/sell` swap methods - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "trade_preimage", - "params": { - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "sell", - "max": true, - "price": 1, - }, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); - - let actual: RpcErrorResponse = serde_json::from_str(&rc.1).unwrap(); - assert_eq!(actual.error_type, "InvalidParam", "Unexpected error_type: {}", rc.1); - let expected = trade_preimage_error::InvalidParam { - param: "max".to_owned(), - reason: "'max' cannot be used with 'sell' or 'buy' method".to_owned(), - }; - assert_eq!(actual.error_data, Some(expected)); - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "trade_preimage", - "params": { - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "sell", - "volume": "7.77", - "price": "2", - }, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!trade_preimage: {}", rc.1); - - let mut actual: RpcSuccessResponse = serde_json::from_str(&rc.1).unwrap(); - actual.result.sort_total_fees(); - - let base_coin_fee = TradeFeeForTest::new("MYCOIN", "0.00000274", false); - let rel_coin_fee = TradeFeeForTest::new("MYCOIN1", "0.00000992", true); - let taker_fee = TradeFeeForTest::new("MYCOIN", "0.01", false); - let fee_to_send_taker_fee = TradeFeeForTest::new("MYCOIN", "0.00000245", false); - - let my_coin_total_fee = TotalTradeFeeForTest::new("MYCOIN", "0.01000519", "0.01000519"); - let my_coin1_total_fee = TotalTradeFeeForTest::new("MYCOIN1", "0.00000992", "0"); - - let expected = TradePreimageResult::TakerPreimage(TakerPreimage { - base_coin_fee, - rel_coin_fee, - taker_fee, - fee_to_send_taker_fee, - total_fees: vec![my_coin_total_fee, my_coin1_total_fee], - }); - assert_eq!(expected, actual.result); - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "trade_preimage", - "params": { - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "buy", - "volume": "7.77", - "price": "2", - }, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!trade_preimage: {}", rc.1); - let mut actual: RpcSuccessResponse = serde_json::from_str(&rc.1).unwrap(); - actual.result.sort_total_fees(); - - let base_coin_fee = TradeFeeForTest::new("MYCOIN", "0.00000496", true); - let rel_coin_fee = TradeFeeForTest::new("MYCOIN1", "0.00000548", false); // fee to send taker payment - let taker_fee = TradeFeeForTest::new("MYCOIN1", "0.02", false); - let fee_to_send_taker_fee = TradeFeeForTest::new("MYCOIN1", "0.0000049", false); - - let my_coin_total_fee = TotalTradeFeeForTest::new("MYCOIN", "0.00000496", "0"); - let my_coin1_total_fee = TotalTradeFeeForTest::new("MYCOIN1", "0.02001038", "0.02001038"); // taker_fee + rel_coin_fee + fee_to_send_taker_fee - - let expected = TradePreimageResult::TakerPreimage(TakerPreimage { - base_coin_fee, - rel_coin_fee, - taker_fee, - fee_to_send_taker_fee, - total_fees: vec![my_coin_total_fee, my_coin1_total_fee], - }); - assert_eq!(expected, actual.result); -} - -#[test] -fn test_trade_preimage_not_sufficient_balance() { - #[track_caller] - fn expect_not_sufficient_balance( - res: &str, - available: BigDecimal, - required: BigDecimal, - locked_by_swaps: Option, - ) { - let actual: RpcErrorResponse = serde_json::from_str(res).unwrap(); - assert_eq!(actual.error_type, "NotSufficientBalance"); - let expected = trade_preimage_error::NotSufficientBalance { - coin: "MYCOIN".to_owned(), - available, - required, - locked_by_swaps, - }; - assert_eq!(actual.error_data, Some(expected)); - } - - let priv_key = random_secp256k1_secret(); - let fill_balance_functor = |amount: BigDecimal| { - let (_ctx, mycoin) = utxo_coin_from_privkey("MYCOIN", priv_key); - let my_address = mycoin.my_address().expect("!my_address"); - fill_address(&mycoin, &my_address, amount, 30); - }; - - let coins = json!([mycoin_conf(1000), mycoin1_conf(2000)]); - let mm = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_dump_log, _dump_dashboard) = mm_dump(&mm.log_path); - - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - - fill_balance_functor(MmNumber::from("0.00001273").to_decimal()); // volume < txfee + dust = 274 + 1000 - // Try sell the max amount with the zero balance. - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "trade_preimage", - "params": { - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "setprice", - "price": 1, - "max": true, - }, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); - let available = MmNumber::from("0.00001273").to_decimal(); - // Required at least 0.00001274 MYCOIN to pay the transaction_fee(0.00000274) and to send a value not less than dust(0.00001) and not less than min_trading_vol (10 * dust). - let required = MmNumber::from("0.00001274").to_decimal(); // TODO: this is not true actually: we can't create orders less that min_trading_vol = 10 * dust - expect_not_sufficient_balance(&rc.1, available, required, Some(MmNumber::from("0").to_decimal())); - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "trade_preimage", - "params": { - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "setprice", - "price": 1, - "volume": 0.1, - }, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); - // Required 0.00001 MYCOIN to pay the transaction fee and the specified 0.1 volume. - let available = MmNumber::from("0.00001273").to_decimal(); - let required = MmNumber::from("0.1000024").to_decimal(); - expect_not_sufficient_balance(&rc.1, available, required, None); - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "trade_preimage", - "params": { - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "setprice", - "price": 1, - "max": true, - }, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); - // balance(0.00001273) - let available = MmNumber::from("0.00001273").to_decimal(); - // required min_tx_amount(0.00001) + transaction_fee(0.00000274) - let required = MmNumber::from("0.00001274").to_decimal(); - expect_not_sufficient_balance(&rc.1, available, required, Some(MmNumber::from("0").to_decimal())); - - fill_balance_functor(MmNumber::from("7.770085").to_decimal()); - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "trade_preimage", - "params": { - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "sell", - "price": 1, - "volume": 7.77, - }, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); - let available = MmNumber::from("7.77009773").to_decimal(); - // `required = volume + fee_to_send_taker_payment + dex_fee + fee_to_send_dex_fee`, - // where `volume = 7.77`, `fee_to_send_taker_payment = 0.00000393, fee_to_send_dex_fee = 0.00000422`, `dex_fee = 0.01`. - // Please note `dex_fee = 7.77 / 777` with dex_fee = 0.01 - // required = 7.77 + 0.01 (dex_fee) + (0.00000393 + 0.00000422) = 7.78000815 - let required = MmNumber::from("7.78000815"); - expect_not_sufficient_balance(&rc.1, available, required.to_decimal(), Some(BigDecimal::from(0))); -} - -/// This test ensures that `trade_preimage` will not succeed on input that will fail on `buy/sell/setprice`. -/// https://github.com/KomodoPlatform/atomicDEX-API/issues/902 -#[test] -fn test_trade_preimage_additional_validation() { - let priv_key = random_secp256k1_secret(); - - let (_ctx, mycoin1) = utxo_coin_from_privkey("MYCOIN1", priv_key); - let my_address = mycoin1.my_address().expect("!my_address"); - fill_address(&mycoin1, &my_address, 20.into(), 30); - - let (_ctx, mycoin) = utxo_coin_from_privkey("MYCOIN", priv_key); - let my_address = mycoin.my_address().expect("!my_address"); - fill_address(&mycoin, &my_address, 10.into(), 30); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(2000)]); - - let mm = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_dump_log, _dump_dashboard) = mm_dump(&mm.log_path); - - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - - // Price is too low - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "trade_preimage", - "params": { - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "setprice", - "price": 0, - "volume": 0.1, - }, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); - let actual: RpcErrorResponse = serde_json::from_str(&rc.1).unwrap(); - assert_eq!(actual.error_type, "PriceTooLow"); - // currently the minimum price is any value above 0 - let expected = trade_preimage_error::PriceTooLow { - price: BigDecimal::from(0), - threshold: BigDecimal::from(0), - }; - assert_eq!(actual.error_data, Some(expected)); - - // volume 0.00001 is too low, min trading volume 0.0001 - let low_volume = BigDecimal::from(1) / BigDecimal::from(100_000); - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "trade_preimage", - "params": { - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "setprice", - "price": 1, - "volume": low_volume, - }, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); - let actual: RpcErrorResponse = serde_json::from_str(&rc.1).unwrap(); - assert_eq!(actual.error_type, "VolumeTooLow"); - // Min MYCOIN trading volume is 0.0001. - let volume_threshold = BigDecimal::from(1) / BigDecimal::from(10_000); - let expected = trade_preimage_error::VolumeTooLow { - coin: "MYCOIN".to_owned(), - volume: low_volume.clone(), - threshold: volume_threshold, - }; - assert_eq!(actual.error_data, Some(expected)); - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "trade_preimage", - "params": { - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "sell", - "price": 1, - "volume": low_volume, - }, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); - let actual: RpcErrorResponse = serde_json::from_str(&rc.1).unwrap(); - assert_eq!(actual.error_type, "VolumeTooLow"); - // Min MYCOIN trading volume is 0.0001. - let volume_threshold = BigDecimal::from(1) / BigDecimal::from(10_000); - let expected = trade_preimage_error::VolumeTooLow { - coin: "MYCOIN".to_owned(), - volume: low_volume, - threshold: volume_threshold, - }; - assert_eq!(actual.error_data, Some(expected)); - - // rel volume is too low - // Min MYCOIN trading volume is 0.0001. - let volume = BigDecimal::from(1) / BigDecimal::from(10_000); - let low_price = BigDecimal::from(1) / BigDecimal::from(10); - // Min MYCOIN1 trading volume is 0.0001, but the actual volume is 0.00001 - let low_rel_volume = &volume * &low_price; - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "trade_preimage", - "params": { - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "sell", - "price": low_price, - "volume": volume, - }, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); - let actual: RpcErrorResponse = serde_json::from_str(&rc.1).unwrap(); - assert_eq!(actual.error_type, "VolumeTooLow"); - // Min MYCOIN1 trading volume is 0.0001. - let volume_threshold = BigDecimal::from(1) / BigDecimal::from(10_000); - let expected = trade_preimage_error::VolumeTooLow { - coin: "MYCOIN1".to_owned(), - volume: low_rel_volume, - threshold: volume_threshold, - }; - assert_eq!(actual.error_data, Some(expected)); -} - -#[test] -fn test_trade_preimage_legacy() { - let priv_key = random_secp256k1_secret(); - let (_ctx, mycoin) = utxo_coin_from_privkey("MYCOIN", priv_key); - let my_address = mycoin.my_address().expect("!my_address"); - fill_address(&mycoin, &my_address, 10.into(), 30); - let (_ctx, mycoin1) = utxo_coin_from_privkey("MYCOIN1", priv_key); - let my_address = mycoin1.my_address().expect("!my_address"); - fill_address(&mycoin1, &my_address, 20.into(), 30); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(2000)]); - let mm = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_dump_log, _dump_dashboard) = mm_dump(&mm.log_path); - - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "method": "trade_preimage", - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "setprice", - "max": true, - "price": "1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!trade_preimage: {}", rc.1); - let _: TradePreimageResponse = serde_json::from_str(&rc.1).unwrap(); - - // vvv test a taker method vvv - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "method": "trade_preimage", - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "sell", - "volume": "7.77", - "price": "2", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!trade_preimage: {}", rc.1); - let _: TradePreimageResponse = serde_json::from_str(&rc.1).unwrap(); - - // vvv test the error response vvv - - // `max` field is not supported for `buy/sell` swap methods - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "method": "trade_preimage", - "base": "MYCOIN", - "rel": "MYCOIN1", - "swap_method": "sell", - "max": true, - "price": "1", - }))) - .unwrap(); - assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); - assert!(rc - .1 - .contains("Incorrect use of the 'max' parameter: 'max' cannot be used with 'sell' or 'buy' method")); -} - -#[test] -fn test_get_max_taker_vol() { - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "max_taker_vol", - "coin": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!max_taker_vol: {}", rc.1); - let json: MaxTakerVolResponse = serde_json::from_str(&rc.1).unwrap(); - // the result of equation `max_vol + max_vol / 777 + 0.00000274 + 0.00000245 = 1` - // derived from `max_vol = balance - locked - trade_fee - fee_to_send_taker_fee - dex_fee(max_vol)` - // where balance = 1, locked = 0, trade_fee = fee_to_send_taker_fee = 0.00001, dex_fee = max_vol / 777 - let expected = MmNumber::from((77699596737, 77800000000)).to_fraction(); - assert_eq!(json.result, expected); - assert_eq!(json.coin, "MYCOIN1"); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "sell", - "base": "MYCOIN1", - "rel": "MYCOIN", - "price": 1, - "volume": json.result, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!sell: {}", rc.1); - - block_on(mm_alice.stop()).unwrap(); -} - -// https://github.com/KomodoPlatform/atomicDEX-API/issues/733 -#[test] -fn test_get_max_taker_vol_dex_fee_min_tx_amount() { - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", "0.00532845".parse().unwrap()); - let coins = json!([mycoin_conf(10000), mycoin1_conf(10000)]); - let mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "max_taker_vol", - "coin": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!max_taker_vol: {}", rc.1); - let json: Json = serde_json::from_str(&rc.1).unwrap(); - // the result of equation x + 0.00001 (dex fee) + 0.0000485 (miner fee 2740 + 2450) = 0.00532845 - assert_eq!(json["result"]["numer"], Json::from("105331")); - assert_eq!(json["result"]["denom"], Json::from("20000000")); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "sell", - "base": "MYCOIN1", - "rel": "MYCOIN", - "price": 1, - "volume": { - "numer": json["result"]["numer"], - "denom": json["result"]["denom"], - } - }))) - .unwrap(); - assert!(rc.0.is_success(), "!sell: {}", rc.1); - - block_on(mm_alice.stop()).unwrap(); -} - -/// Test if the `max_taker_vol` cannot return a volume less than the coin's dust. -/// The minimum required balance for trading can be obtained by solving the equation: -/// `volume + taker_fee + trade_fee + fee_to_send_taker_fee = x`. -/// Let `dust = 0.000728` like for Qtum, `trade_fee = 0.0001`, `fee_to_send_taker_fee = 0.0001` and `taker_fee` is the `0.000728` threshold, -/// therefore to find a minimum required balance, we should pass the `dust` as the `volume` into the equation above: -/// `2 * 0.000728 + 0.00002740 + 0.00002450 = x`, so `x = 0.0014041` -#[test] -fn test_get_max_taker_vol_dust_threshold() { - // first, try to test with the balance slightly less than required - let (_ctx, coin, priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", "0.0014041".parse().unwrap()); - let coins = json!([ - mycoin_conf(10000), - {"coin":"MYCOIN1","asset":"MYCOIN1","txversion":4,"overwintered":1,"txfee":10000,"protocol":{"type":"UTXO"},"dust":72800} - ]); - let mm = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm.log_path); - - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "method": "max_taker_vol", - "coin": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!max_taker_vol {}", rc.1); - let json: Json = serde_json::from_str(&rc.1).unwrap(); - let result: MmNumber = serde_json::from_value(json["result"].clone()).unwrap(); - assert!(result.is_zero()); - - fill_address(&coin, &coin.my_address().unwrap(), "0.0002".parse().unwrap(), 30); //00699910 - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "method": "max_taker_vol", - "coin": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!max_taker_vol: {}", rc.1); - let json: Json = serde_json::from_str(&rc.1).unwrap(); - // the result of equation x + 0.000728 (dex fee) + 0.00004220 + 0.00003930 (miner fees) = 0.0016041, x > dust - assert_eq!(json["result"]["numer"], Json::from("3973")); - assert_eq!(json["result"]["denom"], Json::from("5000000")); - - block_on(mm.stop()).unwrap(); -} - -#[test] -fn test_get_max_taker_vol_with_kmd() { - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1.into()); - let coins = json!([mycoin_conf(10000), mycoin1_conf(10000), kmd_conf(10000)]); - let mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - let electrum = block_on(enable_electrum( - &mm_alice, - "KMD", - false, - &[ - "electrum1.cipig.net:10001", - "electrum2.cipig.net:10001", - "electrum3.cipig.net:10001", - ], - )); - log!("{:?}", electrum); - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "max_taker_vol", - "coin": "MYCOIN1", - "trade_with": "KMD", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!max_taker_vol: {}", rc.1); - let json: Json = serde_json::from_str(&rc.1).unwrap(); - // the result of equation x + x * 9 / 7770 + 0.00002740 + 0.00002450 = 1 - assert_eq!(json["result"]["numer"], Json::from("2589865579")); - assert_eq!(json["result"]["denom"], Json::from("2593000000")); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "sell", - "base": "MYCOIN1", - "rel": "KMD", - "price": 1, - "volume": { - "numer": json["result"]["numer"], - "denom": json["result"]["denom"], - } - }))) - .unwrap(); - assert!(rc.0.is_success(), "!sell: {}", rc.1); - - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -fn test_get_max_maker_vol() { - let (_ctx, _, priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(priv_key)), &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - let (_dump_log, _dump_dashboard) = mm_dump(&mm.log_path); - - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); - - // 1 - tx_fee (274) - let expected_volume = MmNumber::from("0.99999726"); - let expected = MaxMakerVolResponse { - coin: "MYCOIN1".to_string(), - volume: MmNumberMultiRepr::from(expected_volume.clone()), - balance: MmNumberMultiRepr::from(1), - locked_by_swaps: MmNumberMultiRepr::from(0), - }; - let actual = block_on(max_maker_vol(&mm, "MYCOIN1")).unwrap::(); - assert_eq!(actual, expected); - - let res = block_on(set_price(&mm, "MYCOIN1", "MYCOIN", "1", "0", true, None)); - assert_eq!(res.result.max_base_vol, expected_volume.to_decimal()); -} - -#[test] -fn test_get_max_maker_vol_error() { - let priv_key = random_secp256k1_secret(); - let coins = json!([mycoin_conf(1000)]); - let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(priv_key)), &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - let (_dump_log, _dump_dashboard) = mm_dump(&mm.log_path); - - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - - let actual_error = block_on(max_maker_vol(&mm, "MYCOIN")).unwrap_err::(); - let expected_error = max_maker_vol_error::NotSufficientBalance { - coin: "MYCOIN".to_owned(), - available: 0.into(), - // tx_fee - required: BigDecimal::from(1000) / BigDecimal::from(100_000_000), - locked_by_swaps: None, - }; - assert_eq!(actual_error.error_type, "NotSufficientBalance"); - assert_eq!(actual_error.error_data, Some(expected_error)); -} - -#[test] -fn test_set_price_max() { - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - // the result of equation x + 0.00001 = 1 - "volume": { - "numer":"99999", - "denom":"100000" - }, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - // it is slightly more than previous volume so it should fail - "volume": { - "numer":"100000", - "denom":"100000" - }, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "setprice success, but should fail: {}", rc.1); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -fn swaps_should_stop_on_stop_rpc() { - let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 2000.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let mut mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "max": true, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - let mut uuids = Vec::with_capacity(3); - - for _ in 0..3 { - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": "1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - let buy: Json = serde_json::from_str(&rc.1).unwrap(); - uuids.push(buy["result"]["uuid"].as_str().unwrap().to_owned()); - } - for uuid in uuids.iter() { - block_on(mm_bob.wait_for_log(22., |log| { - log.contains(&format!( - "Entering the maker_swap_loop MYCOIN/MYCOIN1 with uuid: {uuid}" - )) - })) - .unwrap(); - block_on(mm_alice.wait_for_log(22., |log| { - log.contains(&format!( - "Entering the taker_swap_loop MYCOIN/MYCOIN1 with uuid: {uuid}" - )) - })) - .unwrap(); - } - thread::sleep(Duration::from_secs(3)); - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); - for uuid in uuids { - block_on(mm_bob.wait_for_log_after_stop(22., |log| log.contains(&format!("swap {uuid} stopped")))).unwrap(); - block_on(mm_alice.wait_for_log_after_stop(22., |log| log.contains(&format!("swap {uuid} stopped")))).unwrap(); - } -} - -#[test] -fn test_maker_order_should_kick_start_and_appear_in_orderbook_on_restart() { - let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut bob_conf = json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }); - let mm_bob = MarketMakerIt::start(bob_conf.clone(), "pass".to_string(), None).unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "max": true, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - // mm_bob using same DB dir that should kick start the order - bob_conf["dbdir"] = mm_bob.folder.join("DB").to_str().unwrap().into(); - bob_conf["log"] = mm_bob.folder.join("mm2_dup.log").to_str().unwrap().into(); - block_on(mm_bob.stop()).unwrap(); - - let mm_bob_dup = MarketMakerIt::start(bob_conf, "pass".to_string(), None).unwrap(); - let (_bob_dup_dump_log, _bob_dup_dump_dashboard) = mm_dump(&mm_bob_dup.log_path); - log!("{:?}", block_on(enable_native(&mm_bob_dup, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob_dup, "MYCOIN1", &[], None))); - - thread::sleep(Duration::from_secs(2)); - - log!("Get MYCOIN/MYCOIN1 orderbook on Bob side"); - let rc = block_on(mm_bob_dup.rpc(&json!({ - "userpass": mm_bob_dup.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("Bob orderbook {:?}", bob_orderbook); - let asks = bob_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 1, "Bob MYCOIN/MYCOIN1 orderbook must have exactly 1 asks"); -} - -#[test] -fn test_maker_order_should_not_kick_start_and_appear_in_orderbook_if_balance_is_withdrawn() { - let (_ctx, coin, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut bob_conf = json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }); - let mm_bob = MarketMakerIt::start(bob_conf.clone(), "pass".to_string(), None).unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "max": true, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - let res: SetPriceResponse = serde_json::from_str(&rc.1).unwrap(); - let uuid = res.result.uuid; - - // mm_bob using same DB dir that should kick start the order - bob_conf["dbdir"] = mm_bob.folder.join("DB").to_str().unwrap().into(); - bob_conf["log"] = mm_bob.folder.join("mm2_dup.log").to_str().unwrap().into(); - block_on(mm_bob.stop()).unwrap(); - - let withdraw = block_on_f01(coin.withdraw(WithdrawRequest::new_max( - "MYCOIN".to_string(), - "RRYmiZSDo3UdHHqj1rLKf8cbJroyv9NxXw".to_string(), - ))) - .unwrap(); - block_on_f01(coin.send_raw_tx(&hex::encode(&withdraw.tx.tx_hex().unwrap().0))).unwrap(); - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: withdraw.tx.tx_hex().unwrap().0.to_owned(), - confirmations: 1, - requires_nota: false, - wait_until: wait_until_sec(10), - check_every: 1, - }; - block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - - let mm_bob_dup = MarketMakerIt::start(bob_conf, "pass".to_string(), None).unwrap(); - let (_bob_dup_dump_log, _bob_dup_dump_dashboard) = mm_dump(&mm_bob_dup.log_path); - log!("{:?}", block_on(enable_native(&mm_bob_dup, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob_dup, "MYCOIN1", &[], None))); - - thread::sleep(Duration::from_secs(2)); - - log!("Get MYCOIN/MYCOIN1 orderbook on Bob side"); - let rc = block_on(mm_bob_dup.rpc(&json!({ - "userpass": mm_bob_dup.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("Bob orderbook {:?}", bob_orderbook); - let asks = bob_orderbook["asks"].as_array().unwrap(); - assert!(asks.is_empty(), "Bob MYCOIN/MYCOIN1 orderbook must not have asks"); - - let rc = block_on(mm_bob_dup.rpc(&json!({ - "userpass": mm_bob_dup.userpass, - "method": "my_orders", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!my_orders: {}", rc.1); - - let res: MyOrdersRpcResult = serde_json::from_str(&rc.1).unwrap(); - assert!(res.result.maker_orders.is_empty(), "Bob maker orders must be empty"); - - let order_path = mm_bob.folder.join(format!( - "DB/{}/ORDERS/MY/MAKER/{}.json", - hex::encode(rmd160_from_priv(bob_priv_key).take()), - uuid - )); - - log!("Order path {}", order_path.display()); - assert!(!order_path.exists()); -} - -#[test] -fn test_maker_order_kick_start_should_trigger_subscription_and_match() { - let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1000.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - - let relay_conf = json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": "relay", - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }); - let relay = MarketMakerIt::start(relay_conf, "pass".to_string(), None).unwrap(); - let (_relay_dump_log, _relay_dump_dashboard) = mm_dump(&relay.log_path); - - let mut bob_conf = json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", relay.ip)], - }); - let mm_bob = MarketMakerIt::start(bob_conf.clone(), "pass".to_string(), None).unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let mut mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", relay.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "max": true, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - // mm_bob using same DB dir that should kick start the order - bob_conf["dbdir"] = mm_bob.folder.join("DB").to_str().unwrap().into(); - bob_conf["log"] = mm_bob.folder.join("mm2_dup.log").to_str().unwrap().into(); - block_on(mm_bob.stop()).unwrap(); - - let mut mm_bob_dup = MarketMakerIt::start(bob_conf, "pass".to_string(), None).unwrap(); - let (_bob_dup_dump_log, _bob_dup_dump_dashboard) = mm_dump(&mm_bob_dup.log_path); - log!("{:?}", block_on(enable_native(&mm_bob_dup, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob_dup, "MYCOIN1", &[], None))); - - log!("Give restarted Bob 2 seconds to kickstart the order"); - thread::sleep(Duration::from_secs(2)); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": 1, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - - block_on(mm_bob_dup.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); -} - -#[test] -fn test_orders_should_match_on_both_nodes_with_same_priv() { - let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 2000.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let mut mm_alice_1 = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_1_dump_log, _alice_1_dump_dashboard) = mm_dump(&mm_alice_1.log_path); - - let mut mm_alice_2 = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_2_dump_log, _alice_2_dump_dashboard) = mm_dump(&mm_alice_2.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice_1, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice_1, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice_2, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice_2, "MYCOIN1", &[], None))); - - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "max": true, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - let rc = block_on(mm_alice_1.rpc(&json!({ - "userpass": mm_alice_1.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": "1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - - block_on(mm_alice_1.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - - let rc = block_on(mm_alice_2.rpc(&json!({ - "userpass": mm_alice_2.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": "1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - - block_on(mm_alice_2.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice_1.stop()).unwrap(); - block_on(mm_alice_2.stop()).unwrap(); -} - -#[test] -fn test_maker_and_taker_order_created_with_same_priv_should_not_match() { - let (_ctx, coin, priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let (_ctx, coin1, _) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1000.into()); - fill_address(&coin1, &coin.my_address().unwrap(), 1000.into(), 30); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let mut mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_1_dump_log, _alice_1_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "max": true, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": "1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - - block_on(mm_bob.wait_for_log(5., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap_err(); - block_on(mm_alice.wait_for_log(5., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap_err(); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -fn test_taker_order_converted_to_maker_should_cancel_properly_when_matched() { - let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 2000.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let mut mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "sell", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": 1, - "timeout": 2, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!sell: {}", rc.1); - - log!("Give Bob 4 seconds to convert order to maker"); - block_on(Timer::sleep(4.)); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": 1, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - - block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - - log!("Give Bob 2 seconds to cancel the order"); - thread::sleep(Duration::from_secs(2)); - log!("Get my_orders on Bob side"); - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "my_orders", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!my_orders: {}", rc.1); - let my_orders_json: Json = serde_json::from_str(&rc.1).unwrap(); - let maker_orders: HashMap = - serde_json::from_value(my_orders_json["result"]["maker_orders"].clone()).unwrap(); - assert!(maker_orders.is_empty()); - - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("Bob orderbook {:?}", bob_orderbook); - let asks = bob_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 0, "Bob MYCOIN/MYCOIN1 orderbook must be empty"); - - log!("Get MYCOIN/MYCOIN1 orderbook on Alice side"); - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let alice_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); - log!("Alice orderbook {:?}", alice_orderbook); - let asks = alice_orderbook["asks"].as_array().unwrap(); - assert_eq!(asks.len(), 0, "Alice MYCOIN/MYCOIN1 orderbook must be empty"); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -fn test_utxo_merge() { - let timeout = 30; // timeout if test takes more than 30 seconds to run - let (_ctx, coin, privkey) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - // fill several times to have more UTXOs on address - fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); - fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); - fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); - fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(privkey)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let native = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "enable", - "coin": "MYCOIN", - "mm2": 1, - "utxo_merge_params": { - "merge_at": 2, - "check_every": 1, - } - }))) - .unwrap(); - assert!(native.0.is_success(), "'enable' failed: {}", native.1); - log!("Enable result {}", native.1); - - block_on(mm_bob.wait_for_log(4., |log| log.contains("Starting UTXO merge loop for coin MYCOIN"))).unwrap(); - - block_on(mm_bob.wait_for_log(4., |log| { - log.contains("UTXO merge of 5 outputs successful for coin=MYCOIN, tx_hash") - })) - .unwrap(); - - thread::sleep(Duration::from_secs(2)); - let address = block_on(coin.as_ref().derivation_method.unwrap_single_addr()); - let (unspents, _) = block_on(coin.get_unspent_ordered_list(&address)).unwrap(); - assert_eq!(unspents.len(), 1); -} - -#[test] -fn test_utxo_merge_max_merge_at_once() { - let timeout = 30; // timeout if test takes more than 30 seconds to run - let (_ctx, coin, privkey) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - // fill several times to have more UTXOs on address - fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); - fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); - fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); - fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(privkey)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let native = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "enable", - "coin": "MYCOIN", - "mm2": 1, - "utxo_merge_params": { - "merge_at": 3, - "check_every": 1, - "max_merge_at_once": 4, - } - }))) - .unwrap(); - assert!(native.0.is_success(), "'enable' failed: {}", native.1); - log!("Enable result {}", native.1); - - block_on(mm_bob.wait_for_log(4., |log| log.contains("Starting UTXO merge loop for coin MYCOIN"))).unwrap(); - - block_on(mm_bob.wait_for_log(4., |log| { - log.contains("UTXO merge of 4 outputs successful for coin=MYCOIN, tx_hash") - })) - .unwrap(); - - thread::sleep(Duration::from_secs(2)); - let address = block_on(coin.as_ref().derivation_method.unwrap_single_addr()); - let (unspents, _) = block_on(coin.get_unspent_ordered_list(&address)).unwrap(); - // 4 utxos are merged of 5 so the resulting unspents len must be 2 - assert_eq!(unspents.len(), 2); -} - -#[test] -fn test_consolidate_utxos_rpc() { - let timeout = 30; // timeout if test takes more than 30 seconds to run - let utxos = 50; - let (_ctx, coin, privkey) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - - // fill several times to have more UTXOs on address - for i in 1..=utxos { - fill_address(&coin, &coin.my_address().unwrap(), i.into(), timeout); - } - - let coins = json!([mycoin_conf(1000)]); - let mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(privkey)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - - let consolidate_rpc = |merge_at: u32, merge_at_once: u32| { - block_on(mm_bob.rpc(&json!({ - "mmrpc": "2.0", - "userpass": mm_bob.userpass, - "method": "consolidate_utxos", - "params": { - "coin": "MYCOIN", - "merge_conditions": { - "merge_at": merge_at, - "max_merge_at_once": merge_at_once, - }, - "broadcast": true - } - }))) - .unwrap() - }; - - let res = consolidate_rpc(52, 4); - assert!(!res.0.is_success(), "Expected error for merge_at > utxos: {}", res.1); - - let res = consolidate_rpc(30, 4); - assert!(res.0.is_success(), "Consolidate utxos failed: {}", res.1); - - let res: RpcSuccessResponse = - serde_json::from_str(&res.1).expect("Expected 'RpcSuccessResponse'"); - // Assert that we respected `max_merge_at_once` and merged only 4 UTXOs. - assert_eq!(res.result.consolidated_utxos.len(), 4); - // Assert that we merged the smallest 4 UTXOs. - for i in 1..=4 { - assert_eq!(res.result.consolidated_utxos[i - 1].value, (i as u32).into()); - } - - thread::sleep(Duration::from_secs(2)); - let address = block_on(coin.as_ref().derivation_method.unwrap_single_addr()); - let (unspents, _) = block_on(coin.get_unspent_ordered_list(&address)).unwrap(); - // We have 51 utxos and merged 4 of them which resulted in an extra one. - assert_eq!(unspents.len(), 51 - 4 + 1); -} - -#[test] -fn test_fetch_utxos_rpc() { - let timeout = 30; // timeout if test takes more than 30 seconds to run - let (_ctx, coin, privkey) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - - // fill several times to have more UTXOs on address - for i in 1..=10 { - fill_address(&coin, &coin.my_address().unwrap(), i.into(), timeout); - } - - let coins = json!([mycoin_conf(1000)]); - let mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(privkey)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - - let fetch_utxo_rpc = || { - let res = block_on(mm_bob.rpc(&json!({ - "mmrpc": "2.0", - "userpass": mm_bob.userpass, - "method": "fetch_utxos", - "params": { - "coin": "MYCOIN" - } - }))) - .unwrap(); - assert!(res.0.is_success(), "Fetch UTXOs failed: {}", res.1); - let res: RpcSuccessResponse = - serde_json::from_str(&res.1).expect("Expected 'RpcSuccessResponse'"); - res.result - }; - - let res = fetch_utxo_rpc(); - assert!(res.total_count == 11); - - fill_address(&coin, &coin.my_address().unwrap(), 100.into(), timeout); - thread::sleep(Duration::from_secs(2)); - - let res = fetch_utxo_rpc(); - assert!(res.total_count == 12); - assert!(res.addresses[0].utxos.iter().any(|utxo| utxo.value == 100.into())); -} - -#[test] -fn test_withdraw_not_sufficient_balance() { - let privkey = random_secp256k1_secret(); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mm = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(privkey)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm.log_path); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - - // balance = 0, but amount = 1 - let amount = BigDecimal::from(1); - let withdraw = block_on(mm.rpc(&json!({ - "mmrpc": "2.0", - "userpass": mm.userpass, - "method": "withdraw", - "params": { - "coin": "MYCOIN", - "to": "RJTYiYeJ8eVvJ53n2YbrVmxWNNMVZjDGLh", - "amount": amount, - }, - "id": 0, - }))) - .unwrap(); - - assert!(withdraw.0.is_client_error(), "MYCOIN withdraw: {}", withdraw.1); - log!("error: {:?}", withdraw.1); - let error: RpcErrorResponse = - serde_json::from_str(&withdraw.1).expect("Expected 'RpcErrorResponse'"); - let expected_error = withdraw_error::NotSufficientBalance { - coin: "MYCOIN".to_owned(), - available: 0.into(), - required: amount, - }; - assert_eq!(error.error_type, "NotSufficientBalance"); - assert_eq!(error.error_data, Some(expected_error)); - - // fill the MYCOIN balance - let balance = BigDecimal::from(1) / BigDecimal::from(2); - let (_ctx, coin) = utxo_coin_from_privkey("MYCOIN", privkey); - fill_address(&coin, &coin.my_address().unwrap(), balance.clone(), 30); - - // txfee = 0.00000211, amount = 0.5 => required = 0.50000211 - // but balance = 0.5 - let txfee = BigDecimal::from_str("0.00000211").unwrap(); - let withdraw = block_on(mm.rpc(&json!({ - "mmrpc": "2.0", - "userpass": mm.userpass, - "method": "withdraw", - "params": { - "coin": "MYCOIN", - "to": "RJTYiYeJ8eVvJ53n2YbrVmxWNNMVZjDGLh", - "amount": balance, - }, - "id": 0, - }))) - .unwrap(); - - assert!(withdraw.0.is_client_error(), "MYCOIN withdraw: {}", withdraw.1); - log!("error: {:?}", withdraw.1); - let error: RpcErrorResponse = - serde_json::from_str(&withdraw.1).expect("Expected 'RpcErrorResponse'"); - let expected_error = withdraw_error::NotSufficientBalance { - coin: "MYCOIN".to_owned(), - available: balance.clone(), - required: balance + txfee, - }; - assert_eq!(error.error_type, "NotSufficientBalance"); - assert_eq!(error.error_data, Some(expected_error)); -} - -// https://github.com/KomodoPlatform/atomicDEX-API/issues/1053 -#[test] -fn test_taker_should_match_with_best_price_buy() { - let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 2000.into()); - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 4000.into()); - let (_ctx, _, eve_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 2000.into()); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let mut mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - let mut mm_eve = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(eve_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_eve_dump_log, _eve_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_eve, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_eve, "MYCOIN1", &[], None))); - - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 2, - "max": true, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - let rc = block_on(mm_eve.rpc(&json!({ - "userpass": mm_eve.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "max": true, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - // subscribe alice to the orderbook topic to not miss eve's message - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!alice orderbook: {}", rc.1); - log!("alice orderbook {}", rc.1); - - thread::sleep(Duration::from_secs(1)); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 3, - "volume": "1000", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - let alice_buy: BuyOrSellRpcResult = serde_json::from_str(&rc.1).unwrap(); - - block_on(mm_eve.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - - thread::sleep(Duration::from_secs(2)); - - block_on(check_my_swap_status_amounts( - &mm_alice, - alice_buy.result.uuid, - 1000.into(), - 1000.into(), - )); - block_on(check_my_swap_status_amounts( - &mm_eve, - alice_buy.result.uuid, - 1000.into(), - 1000.into(), - )); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); - block_on(mm_eve.stop()).unwrap(); -} - -// https://github.com/KomodoPlatform/atomicDEX-API/issues/1053 -#[test] -fn test_taker_should_match_with_best_price_sell() { - let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 2000.into()); - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 4000.into()); - let (_ctx, _, eve_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 2000.into()); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let mut mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - let mut mm_eve = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(eve_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_eve_dump_log, _eve_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_eve, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_eve, "MYCOIN1", &[], None))); - - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 2, - "max": true, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - let rc = block_on(mm_eve.rpc(&json!({ - "userpass": mm_eve.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "max": true, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - // subscribe alice to the orderbook topic to not miss eve's message - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!alice orderbook: {}", rc.1); - log!("alice orderbook {}", rc.1); - - thread::sleep(Duration::from_secs(1)); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "sell", - "base": "MYCOIN1", - "rel": "MYCOIN", - "price": "0.1", - "volume": "1000", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - let alice_buy: BuyOrSellRpcResult = serde_json::from_str(&rc.1).unwrap(); - - block_on(mm_eve.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); - - thread::sleep(Duration::from_secs(2)); - - block_on(check_my_swap_status_amounts( - &mm_alice, - alice_buy.result.uuid, - 1000.into(), - 1000.into(), - )); - block_on(check_my_swap_status_amounts( - &mm_eve, - alice_buy.result.uuid, - 1000.into(), - 1000.into(), - )); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); - block_on(mm_eve.stop()).unwrap(); -} - -#[test] -// https://github.com/KomodoPlatform/atomicDEX-API/issues/1074 -fn test_match_utxo_with_eth_taker_sell() { - let alice_passphrase = get_passphrase!(".env.client", "ALICE_PASSPHRASE").unwrap(); - let bob_passphrase = get_passphrase!(".env.seed", "BOB_PASSPHRASE").unwrap(); - let alice_priv_key = key_pair_from_seed(&alice_passphrase).unwrap().private().secret; - let bob_priv_key = key_pair_from_seed(&bob_passphrase).unwrap().private().secret; - - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), alice_priv_key); - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), bob_priv_key); - - let coins = json!([mycoin_conf(1000), eth_dev_conf()]); - - let mut mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let mut mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - block_on(enable_native(&mm_bob, "ETH", &[GETH_RPC_URL], None)); - block_on(enable_native(&mm_alice, "ETH", &[GETH_RPC_URL], None)); - - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "ETH", - "price": 1, - "volume": "0.0001", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "sell", - "base": "ETH", - "rel": "MYCOIN", - "price": 1, - "volume": "0.0001", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!sell: {}", rc.1); - - block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/ETH"))).unwrap(); - block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/ETH"))).unwrap(); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -// https://github.com/KomodoPlatform/atomicDEX-API/issues/1074 -fn test_match_utxo_with_eth_taker_buy() { - let alice_passphrase = get_passphrase!(".env.client", "ALICE_PASSPHRASE").unwrap(); - let bob_passphrase = get_passphrase!(".env.seed", "BOB_PASSPHRASE").unwrap(); - let alice_priv_key = key_pair_from_seed(&alice_passphrase).unwrap().private().secret; - let bob_priv_key = key_pair_from_seed(&bob_passphrase).unwrap().private().secret; - - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), alice_priv_key); - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), bob_priv_key); - let coins = json!([mycoin_conf(1000), eth_dev_conf()]); - let mut mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(bob_priv_key)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let mut mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(alice_priv_key)), - "coins": coins, - "rpc_password": "pass", - "seednodes": vec![format!("{}", mm_bob.ip)], - }), - "pass".to_string(), - None, - ) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - block_on(enable_native(&mm_bob, "ETH", &[GETH_RPC_URL], None)); - - block_on(enable_native(&mm_alice, "ETH", &[GETH_RPC_URL], None)); - - let rc = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "ETH", - "price": 1, - "volume": "0.0001", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - let rc = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "ETH", - "price": 1, - "volume": "0.0001", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - - block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/ETH"))).unwrap(); - block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/ETH"))).unwrap(); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -fn test_locked_amount() { - let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1000.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let bob_conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(bob_priv_key)), &coins); - let mut mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - - let alice_conf = Mm2TestConf::light_node( - &format!("0x{}", hex::encode(alice_priv_key)), - &coins, - &[&mm_bob.ip.to_string()], - ); - let mut mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); - - block_on(start_swaps( - &mut mm_bob, - &mut mm_alice, - &[("MYCOIN", "MYCOIN1")], - 1., - 1., - 777., - )); - - let locked_bob = block_on(get_locked_amount(&mm_bob, "MYCOIN")); - assert_eq!(locked_bob.coin, "MYCOIN"); - - let expected_result: MmNumberMultiRepr = MmNumber::from("777.00000274").into(); // volume + txfee = 777 + 1 + 0.0000274 - assert_eq!(expected_result, locked_bob.locked_amount); - - let locked_alice = block_on(get_locked_amount(&mm_alice, "MYCOIN1")); - assert_eq!(locked_alice.coin, "MYCOIN1"); - - let expected_result: MmNumberMultiRepr = MmNumber::from("778.00000519").into(); // volume + dexfee + txfee + txfee = 777 + 1 + 0.0000245 + 0.00000274 - assert_eq!(expected_result, locked_alice.locked_amount); -} - -async fn enable_eth_with_tokens( - mm: &MarketMakerIt, - platform_coin: &str, - tokens: &[&str], - swap_contract_address: &str, - nodes: &[&str], - balance: bool, -) -> Json { - let erc20_tokens_requests: Vec<_> = tokens.iter().map(|ticker| json!({ "ticker": ticker })).collect(); - let nodes: Vec<_> = nodes.iter().map(|url| json!({ "url": url })).collect(); - - let enable = mm - .rpc(&json!({ - "userpass": mm.userpass, - "method": "enable_eth_with_tokens", - "mmrpc": "2.0", - "params": { - "ticker": platform_coin, - "erc20_tokens_requests": erc20_tokens_requests, - "swap_contract_address": swap_contract_address, - "nodes": nodes, - "tx_history": true, - "get_balances": balance, - } - })) - .await - .unwrap(); - assert_eq!( - enable.0, - StatusCode::OK, - "'enable_eth_with_tokens' failed: {}", - enable.1 - ); - serde_json::from_str(&enable.1).unwrap() -} - -#[test] -fn test_enable_eth_coin_with_token_then_disable() { - let coin = erc20_coin_with_random_privkey(swap_contract()); - - let priv_key = coin.display_priv_key().unwrap(); - let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); - - let conf = Mm2TestConf::seednode(&priv_key, &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_dump_log, _dump_dashboard) = mm.mm_dump(); - log!("log path: {}", mm.log_path.display()); - - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - block_on(enable_eth_with_tokens( - &mm, - "ETH", - &["ERC20DEV"], - &swap_contract, - &[GETH_RPC_URL], - true, - )); - - // Create setprice order - let req = json!({ - "userpass": mm.userpass, - "method": "buy", - "base": "ETH", - "rel": "ERC20DEV", - "price": 1, - "volume": 0.1, - "base_confs": 5, - "base_nota": false, - "rel_confs": 4, - "rel_nota": false, - }); - let make_test_order = block_on(mm.rpc(&req)).unwrap(); - assert_eq!(make_test_order.0, StatusCode::OK); - let order_uuid = Json::from_str(&make_test_order.1).unwrap(); - let order_uuid = order_uuid.get("result").unwrap().get("uuid").unwrap().as_str().unwrap(); - - // Passive ETH while having tokens enabled - let res = block_on(disable_coin(&mm, "ETH", false)); - assert!(res.passivized); - assert!(res.cancelled_orders.contains(order_uuid)); - - // Try to disable ERC20DEV token. - // This should work, because platform coin is still in the memory. - let res = block_on(disable_coin(&mm, "ERC20DEV", false)); - // We expected make_test_order to be cancelled - assert!(!res.passivized); - - // Because it's currently passive, default `disable_coin` should fail. - block_on(disable_coin_err(&mm, "ETH", false)); - // And forced `disable_coin` should not fail - let res = block_on(disable_coin(&mm, "ETH", true)); - assert!(!res.passivized); -} - -#[test] -fn test_platform_coin_mismatch() { - let coin = erc20_coin_with_random_privkey(swap_contract()); - - let priv_key = coin.display_priv_key().unwrap(); - let mut erc20_conf = erc20_dev_conf(&erc20_contract_checksum()); - erc20_conf["protocol"]["protocol_data"]["platform"] = "MATIC".into(); // set a different platform coin - let coins = json!([eth_dev_conf(), erc20_conf]); - - let conf = Mm2TestConf::seednode(&priv_key, &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_dump_log, _dump_dashboard) = mm.mm_dump(); - log!("log path: {}", mm.log_path.display()); - - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - let erc20_tokens_requests = vec![json!({ "ticker": "ERC20DEV" })]; - let nodes = vec![json!({ "url": GETH_RPC_URL })]; - - let enable = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "method": "enable_eth_with_tokens", - "mmrpc": "2.0", - "params": { - "ticker": "ETH", - "erc20_tokens_requests": erc20_tokens_requests, - "swap_contract_address": swap_contract, - "nodes": nodes, - "tx_history": false, - "get_balances": false, - } - }))) - .unwrap(); - assert_eq!( - enable.0, - StatusCode::BAD_REQUEST, - "'enable_eth_with_tokens' must fail with PlatformCoinMismatch", - ); - assert_eq!( - serde_json::from_str::(&enable.1).unwrap()["error_type"] - .as_str() - .unwrap(), - "PlatformCoinMismatch", - ); -} - -#[test] -fn test_enable_eth_coin_with_token_without_balance() { - let coin = erc20_coin_with_random_privkey(swap_contract()); - - let priv_key = coin.display_priv_key().unwrap(); - let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); - - let conf = Mm2TestConf::seednode(&priv_key, &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_dump_log, _dump_dashboard) = mm.mm_dump(); - log!("log path: {}", mm.log_path.display()); - - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - let enable_eth_with_tokens = block_on(enable_eth_with_tokens( - &mm, - "ETH", - &["ERC20DEV"], - &swap_contract, - &[GETH_RPC_URL], - false, - )); - - let enable_eth_with_tokens: RpcV2Response = - serde_json::from_value(enable_eth_with_tokens).unwrap(); - - let (_, eth_balance) = enable_eth_with_tokens - .result - .eth_addresses_infos - .into_iter() - .next() - .unwrap(); - log!("{:?}", eth_balance); - assert!(eth_balance.balances.is_none()); - assert!(eth_balance.tickers.is_none()); - - let (_, erc20_balances) = enable_eth_with_tokens - .result - .erc20_addresses_infos - .into_iter() - .next() - .unwrap(); - assert!(erc20_balances.balances.is_none()); - assert_eq!( - erc20_balances.tickers.unwrap(), - HashSet::from_iter(vec!["ERC20DEV".to_string()]) - ); -} - -#[test] -fn test_eth_swap_contract_addr_negotiation_same_fallback() { - let bob_coin = erc20_coin_with_random_privkey(swap_contract()); - let alice_coin = erc20_coin_with_random_privkey(swap_contract()); - - let bob_priv_key = bob_coin.display_priv_key().unwrap(); - let alice_priv_key = alice_coin.display_priv_key().unwrap(); - - let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); - - let bob_conf = Mm2TestConf::seednode(&bob_priv_key, &coins); - let mut mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); - - let (_bob_dump_log, _bob_dump_dashboard) = mm_bob.mm_dump(); - log!("Bob log path: {}", mm_bob.log_path.display()); - - let alice_conf = Mm2TestConf::light_node(&alice_priv_key, &coins, &[&mm_bob.ip.to_string()]); - let mut mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); - - let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); - log!("Alice log path: {}", mm_alice.log_path.display()); - - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - - dbg!(block_on(enable_eth_coin( - &mm_bob, - "ETH", - &[GETH_RPC_URL], - // using arbitrary address - "0x6c2858f6afac835c43ffda248aea167e1a58436c", - Some(&swap_contract), - false - ))); - - dbg!(block_on(enable_eth_coin( - &mm_bob, - "ERC20DEV", - &[GETH_RPC_URL], - // using arbitrary address - "0x6c2858f6afac835c43ffda248aea167e1a58436c", - Some(&swap_contract), - false - ))); - - dbg!(block_on(enable_eth_coin( - &mm_alice, - "ETH", - &[GETH_RPC_URL], - // using arbitrary address - "0x24abe4c71fc658c01313b6552cd40cd808b3ea80", - Some(&swap_contract), - false - ))); - - dbg!(block_on(enable_eth_coin( - &mm_alice, - "ERC20DEV", - &[GETH_RPC_URL], - // using arbitrary address - "0x24abe4c71fc658c01313b6552cd40cd808b3ea80", - Some(&swap_contract), - false - ))); - - let uuids = block_on(start_swaps( - &mut mm_bob, - &mut mm_alice, - &[("ETH", "ERC20DEV")], - 1., - 1., - 0.0001, - )); - - // give few seconds for swap statuses to be saved - thread::sleep(Duration::from_secs(3)); - - let wait_until = get_utc_timestamp() + 30; - let expected_contract = Json::from(swap_contract.trim_start_matches("0x")); - - block_on(wait_for_swap_contract_negotiation( - &mm_bob, - &uuids[0], - expected_contract.clone(), - wait_until, - )); - block_on(wait_for_swap_contract_negotiation( - &mm_alice, - &uuids[0], - expected_contract, - wait_until, - )); -} - -#[test] -fn test_eth_swap_negotiation_fails_maker_no_fallback() { - let bob_coin = erc20_coin_with_random_privkey(swap_contract()); - let alice_coin = erc20_coin_with_random_privkey(swap_contract()); - - let bob_priv_key = bob_coin.display_priv_key().unwrap(); - let alice_priv_key = alice_coin.display_priv_key().unwrap(); - - let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); - - let bob_conf = Mm2TestConf::seednode(&bob_priv_key, &coins); - let mut mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); - - let (_bob_dump_log, _bob_dump_dashboard) = mm_bob.mm_dump(); - log!("Bob log path: {}", mm_bob.log_path.display()); - - let alice_conf = Mm2TestConf::light_node(&alice_priv_key, &coins, &[&mm_bob.ip.to_string()]); - let mut mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); - - let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); - log!("Alice log path: {}", mm_alice.log_path.display()); - - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - - dbg!(block_on(enable_eth_coin( - &mm_bob, - "ETH", - &[GETH_RPC_URL], - // using arbitrary address - "0x6c2858f6afac835c43ffda248aea167e1a58436c", - None, - false - ))); - - dbg!(block_on(enable_eth_coin( - &mm_bob, - "ERC20DEV", - &[GETH_RPC_URL], - // using arbitrary address - "0x6c2858f6afac835c43ffda248aea167e1a58436c", - None, - false - ))); - - dbg!(block_on(enable_eth_coin( - &mm_alice, - "ETH", - &[GETH_RPC_URL], - // using arbitrary address - "0x24abe4c71fc658c01313b6552cd40cd808b3ea80", - Some(&swap_contract), - false - ))); - - dbg!(block_on(enable_eth_coin( - &mm_alice, - "ERC20DEV", - &[GETH_RPC_URL], - // using arbitrary address - "0x24abe4c71fc658c01313b6552cd40cd808b3ea80", - Some(&swap_contract), - false - ))); - - let uuids = block_on(start_swaps( - &mut mm_bob, - &mut mm_alice, - &[("ETH", "ERC20DEV")], - 1., - 1., - 0.0001, - )); - - // give few seconds for swap statuses to be saved - thread::sleep(Duration::from_secs(3)); - - let wait_until = get_utc_timestamp() + 30; - block_on(wait_for_swap_negotiation_failure(&mm_bob, &uuids[0], wait_until)); - block_on(wait_for_swap_negotiation_failure(&mm_alice, &uuids[0], wait_until)); -} - -#[test] -fn test_trade_base_rel_eth_erc20_coins() { - trade_base_rel(("ETH", "ERC20DEV")); -} - -#[test] -fn test_trade_base_rel_mycoin_mycoin1_coins() { - trade_base_rel(("MYCOIN", "MYCOIN1")); -} - -// run swap with burn pubkey set to alice (no dex fee) -#[test] -fn test_trade_base_rel_mycoin_mycoin1_coins_burnkey_as_alice() { - SET_BURN_PUBKEY_TO_ALICE.set(true); - trade_base_rel(("MYCOIN", "MYCOIN1")); -} - -fn withdraw_and_send( - mm: &MarketMakerIt, - coin: &str, - from: Option, - to: &str, - from_addr: &str, - expected_bal_change: &str, - amount: f64, -) { - let withdraw = block_on(mm.rpc(&json! ({ - "mmrpc": "2.0", - "userpass": mm.userpass, - "method": "withdraw", - "params": { - "coin": coin, - "from": from, - "to": to, - "amount": amount, - }, - "id": 0, - }))) - .unwrap(); - - assert!(withdraw.0.is_success(), "!withdraw: {}", withdraw.1); - let res: RpcSuccessResponse = - serde_json::from_str(&withdraw.1).expect("Expected 'RpcSuccessResponse'"); - let tx_details = res.result; - - let mut expected_bal_change = BigDecimal::from_str(expected_bal_change).expect("!BigDecimal::from_str"); - - let fee_details: TxFeeDetails = serde_json::from_value(tx_details.fee_details).unwrap(); - - if let TxFeeDetails::Eth(fee_details) = fee_details { - if coin == "ETH" { - expected_bal_change -= fee_details.total_fee; - } - } - - assert_eq!(tx_details.to, vec![to.to_owned()]); - assert_eq!(tx_details.my_balance_change, expected_bal_change); - // Todo: Should check the from address for withdraws from another HD wallet address when there is an RPC method for addresses - if from.is_none() { - assert_eq!(tx_details.from, vec![from_addr.to_owned()]); - } - - let send = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "send_raw_transaction", - "coin": coin, - "tx_hex": tx_details.tx_hex, - }))) - .unwrap(); - assert!(send.0.is_success(), "!{} send: {}", coin, send.1); - let send_json: Json = serde_json::from_str(&send.1).unwrap(); - assert_eq!(tx_details.tx_hash, send_json["tx_hash"]); -} - -#[test] -fn test_withdraw_and_send_eth_erc20() { - let privkey = random_secp256k1_secret(); - fill_eth_erc20_with_private_key(privkey); - - let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); - let mm = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 9000, - "dht": "on", // Enable DHT without delay. - "passphrase": format!("0x{}", hex::encode(privkey)), - "coins": coins, - "rpc_password": "pass", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "pass".to_string(), - None, - ) - .unwrap(); - - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("Alice log path: {}", mm.log_path.display()); - - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - let eth_enable = block_on(enable_eth_coin( - &mm, - "ETH", - &[GETH_RPC_URL], - &swap_contract, - None, - false, - )); - let erc20_enable = block_on(enable_eth_coin( - &mm, - "ERC20DEV", - &[GETH_RPC_URL], - &swap_contract, - None, - false, - )); - - withdraw_and_send( - &mm, - "ETH", - None, - "0x4b2d0d6c2c785217457B69B922A2A9cEA98f71E9", - eth_enable["address"].as_str().unwrap(), - "-0.001", - 0.001, - ); - - withdraw_and_send( - &mm, - "ERC20DEV", - None, - "0x4b2d0d6c2c785217457B69B922A2A9cEA98f71E9", - erc20_enable["address"].as_str().unwrap(), - "-0.001", - 0.001, - ); - - // must not allow to withdraw to invalid checksum address - let withdraw = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "mmrpc": "2.0", - "method": "withdraw", - "params": { - "coin": "ETH", - "to": "0x4b2d0d6c2c785217457b69b922a2A9cEA98f71E9", - "amount": "0.001", - }, - "id": 0, - }))) - .unwrap(); - - assert!(withdraw.0.is_client_error(), "ETH withdraw: {}", withdraw.1); - let res: RpcErrorResponse = serde_json::from_str(&withdraw.1).unwrap(); - assert_eq!(res.error_type, "InvalidAddress"); - assert!(res.error.contains("Invalid address checksum")); -} - -#[test] -fn test_withdraw_and_send_hd_eth_erc20() { - const PASSPHRASE: &str = "tank abandon bind salon remove wisdom net size aspect direct source fossil"; - - let KeyPairPolicy::GlobalHDAccount(hd_acc) = CryptoCtx::init_with_global_hd_account(MM_CTX.clone(), PASSPHRASE) - .unwrap() - .key_pair_policy() - .clone() - else { - panic!("Expected 'KeyPairPolicy::GlobalHDAccount'"); - }; - - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - - // Withdraw from HD account 0, change address 0, index 1 - let mut path_to_address = HDAccountAddressId { - account_id: 0, - chain: Bip44Chain::External, - address_id: 1, - }; - let path_to_addr_str = "/0'/0/1"; - let path_to_coin: String = serde_json::from_value(eth_dev_conf()["derivation_path"].clone()).unwrap(); - let derivation_path = path_to_coin.clone() + path_to_addr_str; - let derivation_path = DerivationPath::from_str(&derivation_path).unwrap(); - // Get the private key associated with this account and fill it with eth and erc20 token. - let priv_key = hd_acc.derive_secp256k1_secret(&derivation_path).unwrap(); - fill_eth_erc20_with_private_key(priv_key); - - let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); - - let conf = Mm2TestConf::seednode_with_hd_account(PASSPHRASE, &coins); - let mm_hd = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_mm_dump_log, _mm_dump_dashboard) = mm_hd.mm_dump(); - log!("Alice log path: {}", mm_hd.log_path.display()); - - let eth_enable = block_on(task_enable_eth_with_tokens( - &mm_hd, - "ETH", - &["ERC20DEV"], - &swap_contract, - &[GETH_RPC_URL], - 60, - Some(path_to_address.clone()), - )); - let activation_result = match eth_enable { - EthWithTokensActivationResult::HD(hd) => hd, - _ => panic!("Expected EthWithTokensActivationResult::HD"), - }; - let balance = match activation_result.wallet_balance { - EnableCoinBalanceMap::HD(hd) => hd, - _ => panic!("Expected EnableCoinBalance::HD"), - }; - let account = balance.accounts.first().expect("Expected account at index 0"); - assert_eq!( - account.addresses[1].address, - "0xDe841899aB4A22E23dB21634e54920aDec402397" - ); - assert_eq!(account.addresses[1].balance.len(), 2); - assert_eq!(account.addresses[1].balance.get("ETH").unwrap().spendable, 100.into()); - assert_eq!( - account.addresses[1].balance.get("ERC20DEV").unwrap().spendable, - 100.into() - ); - - withdraw_and_send( - &mm_hd, - "ETH", - Some(path_to_address.clone()), - "0x4b2d0d6c2c785217457B69B922A2A9cEA98f71E9", - &account.addresses[1].address, - "-0.001", - 0.001, - ); - - withdraw_and_send( - &mm_hd, - "ERC20DEV", - Some(path_to_address.clone()), - "0x4b2d0d6c2c785217457B69B922A2A9cEA98f71E9", - &account.addresses[1].address, - "-0.001", - 0.001, - ); - - // Change the address index, the withdrawal should fail. - path_to_address.address_id = 0; - - let withdraw = block_on(mm_hd.rpc(&json! ({ - "mmrpc": "2.0", - "userpass": mm_hd.userpass, - "method": "withdraw", - "params": { - "coin": "ETH", - "from": path_to_address, - "to": "0x4b2d0d6c2c785217457B69B922A2A9cEA98f71E9", - "amount": 0.001, - }, - "id": 0, - }))) - .unwrap(); - assert!(!withdraw.0.is_success(), "!withdraw: {}", withdraw.1); - - // But if we fill it, we should be able to withdraw. - let path_to_addr_str = "/0'/0/0"; - let derivation_path = path_to_coin + path_to_addr_str; - let derivation_path = DerivationPath::from_str(&derivation_path).unwrap(); - let priv_key = hd_acc.derive_secp256k1_secret(&derivation_path).unwrap(); - fill_eth_erc20_with_private_key(priv_key); - - let withdraw = block_on(mm_hd.rpc(&json! ({ - "mmrpc": "2.0", - "userpass": mm_hd.userpass, - "method": "withdraw", - "params": { - "coin": "ETH", - "from": path_to_address, - "to": "0x4b2d0d6c2c785217457B69B922A2A9cEA98f71E9", - "amount": 0.001, - }, - "id": 0, - }))) - .unwrap(); - assert!(withdraw.0.is_success(), "!withdraw: {}", withdraw.1); - - block_on(mm_hd.stop()).unwrap(); -} - -fn check_too_low_volume_order_creation_fails(mm: &MarketMakerIt, base: &str, rel: &str) { - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "setprice", - "base": base, - "rel": rel, - "price": "1", - "volume": "0.00000099", - "cancel_previous": false, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "setprice success, but should be error {}", rc.1); - - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "setprice", - "base": base, - "rel": rel, - "price": "0.00000000000000000099", - "volume": "1", - "cancel_previous": false, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "setprice success, but should be error {}", rc.1); - - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "sell", - "base": base, - "rel": rel, - "price": "1", - "volume": "0.00000099", - }))) - .unwrap(); - assert!(!rc.0.is_success(), "sell success, but should be error {}", rc.1); - - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "buy", - "base": base, - "rel": rel, - "price": "1", - "volume": "0.00000099", - }))) - .unwrap(); - assert!(!rc.0.is_success(), "buy success, but should be error {}", rc.1); -} - -#[test] -// https://github.com/KomodoPlatform/atomicDEX-API/issues/481 -fn test_setprice_buy_sell_too_low_volume() { - let privkey = random_secp256k1_secret(); - - // Fill the addresses with coins. - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); - generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), privkey); - fill_eth_erc20_with_private_key(privkey); - - let coins = json!([ - mycoin_conf(1000), - mycoin1_conf(1000), - eth_dev_conf(), - erc20_dev_conf(&erc20_contract_checksum()) - ]); - let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_dump_log, _dump_dashboard) = mm.mm_dump(); - log!("Log path: {}", mm.log_path.display()); - - // Enable all the coins - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); - - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - dbg!(block_on(enable_eth_coin( - &mm, - "ETH", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); - dbg!(block_on(enable_eth_coin( - &mm, - "ERC20DEV", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); - - check_too_low_volume_order_creation_fails(&mm, "MYCOIN", "ETH"); - check_too_low_volume_order_creation_fails(&mm, "ETH", "MYCOIN"); - check_too_low_volume_order_creation_fails(&mm, "ERC20DEV", "MYCOIN1"); -} - -#[test] -fn test_fill_or_kill_taker_order_should_not_transform_to_maker() { - let privkey = random_secp256k1_secret(); - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); - - let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("MM log path: {}", mm.log_path.display()); - - // Enable coins - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); - - log!("Issue bob MYCOIN/MYCOIN1 sell request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "sell", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": 0.1, - "order_type": { - "type": "FillOrKill" - }, - "timeout": 2, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!sell: {}", rc.1); - let sell_json: Json = serde_json::from_str(&rc.1).unwrap(); - let order_type = sell_json["result"]["order_type"]["type"].as_str(); - assert_eq!(order_type, Some("FillOrKill")); - - log!("Wait for 4 seconds for Bob order to be cancelled"); - block_on(Timer::sleep(4.)); - - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "my_orders", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!my_orders: {}", rc.1); - let my_orders: Json = serde_json::from_str(&rc.1).unwrap(); - let my_maker_orders: HashMap = - serde_json::from_value(my_orders["result"]["maker_orders"].clone()).unwrap(); - let my_taker_orders: HashMap = - serde_json::from_value(my_orders["result"]["taker_orders"].clone()).unwrap(); - assert!(my_maker_orders.is_empty(), "maker_orders must be empty"); - assert!(my_taker_orders.is_empty(), "taker_orders must be empty"); -} - -#[test] -fn test_gtc_taker_order_should_transform_to_maker() { - let privkey = random_secp256k1_secret(); - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); - - let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("MM log path: {}", mm.log_path.display()); - - // Enable coins - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); - - log!("Issue bob MYCOIN/MYCOIN1 sell request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "sell", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": 0.1, - "order_type": { - "type": "GoodTillCancelled" - }, - "timeout": 2, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - let rc_json: Json = serde_json::from_str(&rc.1).unwrap(); - let uuid: String = serde_json::from_value(rc_json["result"]["uuid"].clone()).unwrap(); - - log!("Wait for 4 seconds for Bob order to be converted to maker"); - block_on(Timer::sleep(4.)); - - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "my_orders", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!my_orders: {}", rc.1); - let my_orders: Json = serde_json::from_str(&rc.1).unwrap(); - let my_maker_orders: HashMap = - serde_json::from_value(my_orders["result"]["maker_orders"].clone()).unwrap(); - let my_taker_orders: HashMap = - serde_json::from_value(my_orders["result"]["taker_orders"].clone()).unwrap(); - assert_eq!(1, my_maker_orders.len(), "maker_orders must have exactly 1 order"); - assert!(my_taker_orders.is_empty(), "taker_orders must be empty"); - let order_path = mm.folder.join(format!( - "DB/{}/ORDERS/MY/MAKER/{}.json", - hex::encode(rmd160_from_passphrase(&format!("0x{}", hex::encode(privkey)))), - uuid - )); - log!("Order path {}", order_path.display()); - assert!(order_path.exists()); -} - -#[test] -fn test_set_price_must_save_order_to_db() { - let private_key_str = erc20_coin_with_random_privkey(swap_contract()) - .display_priv_key() - .unwrap(); - let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); - - let conf = Mm2TestConf::seednode(&private_key_str, &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("MM log path: {}", mm.log_path.display()); - - // Enable coins - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - dbg!(block_on(enable_eth_coin( - &mm, - "ETH", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); - dbg!(block_on(enable_eth_coin( - &mm, - "ERC20DEV", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); - - log!("Issue bob ETH/ERC20DEV sell request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "setprice", - "base": "ETH", - "rel": "ERC20DEV", - "price": 1, - "volume": 0.1 - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - let rc_json: Json = serde_json::from_str(&rc.1).unwrap(); - let uuid: String = serde_json::from_value(rc_json["result"]["uuid"].clone()).unwrap(); - let order_path = mm.folder.join(format!( - "DB/{}/ORDERS/MY/MAKER/{}.json", - hex::encode(rmd160_from_passphrase(&private_key_str)), - uuid - )); - assert!(order_path.exists()); -} - -#[test] -fn test_set_price_response_format() { - let privkey = random_secp256k1_secret(); - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); - - let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("MM log path: {}", mm.log_path.display()); - - // Enable coins - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + assert!(!rc.0.is_success(), "setprice success, but should be error {}", rc.1); - log!("Issue bob MYCOIN/MYCOIN1 sell request"); let rc = block_on(mm.rpc(&json! ({ "userpass": mm.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": 0.1 + "method": "sell", + "base": base, + "rel": rel, + "price": "1", + "volume": "0.00000099", }))) .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - let rc_json: Json = serde_json::from_str(&rc.1).unwrap(); - let _: BigDecimal = serde_json::from_value(rc_json["result"]["max_base_vol"].clone()).unwrap(); - let _: BigDecimal = serde_json::from_value(rc_json["result"]["min_base_vol"].clone()).unwrap(); - let _: BigDecimal = serde_json::from_value(rc_json["result"]["price"].clone()).unwrap(); - - let _: BigRational = serde_json::from_value(rc_json["result"]["max_base_vol_rat"].clone()).unwrap(); - let _: BigRational = serde_json::from_value(rc_json["result"]["min_base_vol_rat"].clone()).unwrap(); - let _: BigRational = serde_json::from_value(rc_json["result"]["price_rat"].clone()).unwrap(); -} - -#[test] -fn test_buy_response_format() { - let privkey = random_secp256k1_secret(); - generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), privkey); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); - - let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("MM log path: {}", mm.log_path.display()); - - // Enable coins - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + assert!(!rc.0.is_success(), "sell success, but should be error {}", rc.1); - log!("Issue bob buy request"); let rc = block_on(mm.rpc(&json! ({ "userpass": mm.userpass, "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": 0.1, - "base_confs": 5, - "base_nota": true, - "rel_confs": 4, - "rel_nota": false, + "base": base, + "rel": rel, + "price": "1", + "volume": "0.00000099", }))) .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - let _: BuyOrSellRpcResult = serde_json::from_str(&rc.1).unwrap(); + assert!(!rc.0.is_success(), "buy success, but should be error {}", rc.1); } #[test] -fn test_sell_response_format() { +// https://github.com/KomodoPlatform/atomicDEX-API/issues/481 +fn test_setprice_buy_sell_too_low_volume() { let privkey = random_secp256k1_secret(); - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); + // Fill the addresses with coins. + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); + generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), privkey); + fill_eth_erc20_with_private_key(privkey); + let coins = json!([ + mycoin_conf(1000), + mycoin1_conf(1000), + eth_dev_conf(), + erc20_dev_conf(&erc20_contract_checksum()) + ]); let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("MM log path: {}", mm.log_path.display()); + let (_dump_log, _dump_dashboard) = mm.mm_dump(); + log!("Log path: {}", mm.log_path.display()); - // Enable coins + // Enable all the coins log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); - log!("Issue bob sell request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "sell", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": 0.1, - "base_confs": 5, - "base_nota": true, - "rel_confs": 4, - "rel_nota": false, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!sell: {}", rc.1); - let _: BuyOrSellRpcResult = serde_json::from_str(&rc.1).unwrap(); -} - -#[test] -fn test_set_price_conf_settings() { - let private_key_str = erc20_coin_with_random_privkey(swap_contract()) - .display_priv_key() - .unwrap(); - - let coins = json!([eth_dev_conf(),{"coin":"ERC20DEV","name":"erc20dev","protocol":{"type":"ERC20","protocol_data":{"platform":"ETH","contract_address":erc20_contract_checksum()}},"required_confirmations":2},]); - - let conf = Mm2TestConf::seednode(&private_key_str, &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("MM log path: {}", mm.log_path.display()); - - // Enable coins - let swap_contract = format!("0x{}", hex::encode(swap_contract())); + let swap_contract = swap_contract_checksum(); dbg!(block_on(enable_eth_coin( &mm, "ETH", @@ -4797,277 +286,82 @@ fn test_set_price_conf_settings() { false ))); - log!("Issue bob sell request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "setprice", - "base": "ETH", - "rel": "ERC20DEV", - "price": 1, - "volume": 0.1, - "base_confs": 5, - "base_nota": true, - "rel_confs": 4, - "rel_nota": false, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - let json: Json = serde_json::from_str(&rc.1).unwrap(); - assert_eq!(json["result"]["conf_settings"]["base_confs"], Json::from(5)); - assert_eq!(json["result"]["conf_settings"]["base_nota"], Json::from(true)); - assert_eq!(json["result"]["conf_settings"]["rel_confs"], Json::from(4)); - assert_eq!(json["result"]["conf_settings"]["rel_nota"], Json::from(false)); - - // must use coin config as defaults if not set in request - log!("Issue bob sell request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "setprice", - "base": "ETH", - "rel": "ERC20DEV", - "price": 1, - "volume": 0.1, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - let json: Json = serde_json::from_str(&rc.1).unwrap(); - assert_eq!(json["result"]["conf_settings"]["base_confs"], Json::from(1)); - assert_eq!(json["result"]["conf_settings"]["base_nota"], Json::from(false)); - assert_eq!(json["result"]["conf_settings"]["rel_confs"], Json::from(2)); - assert_eq!(json["result"]["conf_settings"]["rel_nota"], Json::from(false)); + check_too_low_volume_order_creation_fails(&mm, "MYCOIN", "ETH"); + check_too_low_volume_order_creation_fails(&mm, "ETH", "MYCOIN"); + check_too_low_volume_order_creation_fails(&mm, "ERC20DEV", "MYCOIN1"); } -#[test] -fn test_buy_conf_settings() { - let private_key_str = erc20_coin_with_random_privkey(swap_contract()) - .display_priv_key() - .unwrap(); - - let coins = json!([eth_dev_conf(),{"coin":"ERC20DEV","name":"erc20dev","protocol":{"type":"ERC20","protocol_data":{"platform":"ETH","contract_address":erc20_contract_checksum()}},"required_confirmations":2},]); - - let conf = Mm2TestConf::seednode(&private_key_str, &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("MM log path: {}", mm.log_path.display()); - - // Enable coins - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - dbg!(block_on(enable_eth_coin( - &mm, - "ETH", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); - dbg!(block_on(enable_eth_coin( - &mm, - "ERC20DEV", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); - - log!("Issue bob buy request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "buy", - "base": "ETH", - "rel": "ERC20DEV", - "price": 1, - "volume": 0.1, - "base_confs": 5, - "base_nota": true, - "rel_confs": 4, - "rel_nota": false, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - let json: Json = serde_json::from_str(&rc.1).unwrap(); - assert_eq!(json["result"]["conf_settings"]["base_confs"], Json::from(5)); - assert_eq!(json["result"]["conf_settings"]["base_nota"], Json::from(true)); - assert_eq!(json["result"]["conf_settings"]["rel_confs"], Json::from(4)); - assert_eq!(json["result"]["conf_settings"]["rel_nota"], Json::from(false)); +// ============================================================================= +// Cross-Chain Orderbook Depth Tests +// These tests verify orderbook depth calculation across multiple chain types +// ============================================================================= - // must use coin config as defaults if not set in request - log!("Issue bob buy request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "buy", - "base": "ETH", - "rel": "ERC20DEV", - "price": 1, - "volume": 0.1, +fn request_and_check_orderbook_depth(mm_alice: &MarketMakerIt) { + let rc = block_on(mm_alice.rpc(&json! ({ + "userpass": mm_alice.userpass, + "method": "orderbook_depth", + "pairs": [("MYCOIN", "MYCOIN1"), ("MYCOIN", "ETH"), ("MYCOIN1", "ETH")], }))) .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - let json: Json = serde_json::from_str(&rc.1).unwrap(); - assert_eq!(json["result"]["conf_settings"]["base_confs"], Json::from(1)); - assert_eq!(json["result"]["conf_settings"]["base_nota"], Json::from(false)); - assert_eq!(json["result"]["conf_settings"]["rel_confs"], Json::from(2)); - assert_eq!(json["result"]["conf_settings"]["rel_nota"], Json::from(false)); -} - -#[test] -fn test_sell_conf_settings() { - let private_key_str = erc20_coin_with_random_privkey(swap_contract()) - .display_priv_key() + assert!(rc.0.is_success(), "!orderbook_depth: {}", rc.1); + let response: OrderbookDepthResponse = serde_json::from_str(&rc.1).unwrap(); + let mycoin_mycoin1 = response + .result + .iter() + .find(|pair_depth| pair_depth.pair.0 == "MYCOIN" && pair_depth.pair.1 == "MYCOIN1") .unwrap(); + assert_eq!(3, mycoin_mycoin1.depth.asks); + assert_eq!(2, mycoin_mycoin1.depth.bids); - let coins = json!([eth_dev_conf(),{"coin":"ERC20DEV","name":"erc20dev","protocol":{"type":"ERC20","protocol_data":{"platform":"ETH","contract_address":erc20_contract_checksum()}},"required_confirmations":2},]); - - let conf = Mm2TestConf::seednode(&private_key_str, &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("MM log path: {}", mm.log_path.display()); - - // Enable coins - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - dbg!(block_on(enable_eth_coin( - &mm, - "ETH", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); - dbg!(block_on(enable_eth_coin( - &mm, - "ERC20DEV", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); - - log!("Issue bob sell request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "sell", - "base": "ETH", - "rel": "ERC20DEV", - "price": 1, - "volume": 0.1, - "base_confs": 5, - "base_nota": true, - "rel_confs": 4, - "rel_nota": false, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!sell: {}", rc.1); - let json: Json = serde_json::from_str(&rc.1).unwrap(); - assert_eq!(json["result"]["conf_settings"]["base_confs"], Json::from(5)); - assert_eq!(json["result"]["conf_settings"]["base_nota"], Json::from(true)); - assert_eq!(json["result"]["conf_settings"]["rel_confs"], Json::from(4)); - assert_eq!(json["result"]["conf_settings"]["rel_nota"], Json::from(false)); + let mycoin_eth = response + .result + .iter() + .find(|pair_depth| pair_depth.pair.0 == "MYCOIN" && pair_depth.pair.1 == "ETH") + .unwrap(); + assert_eq!(1, mycoin_eth.depth.asks); + assert_eq!(1, mycoin_eth.depth.bids); - // must use coin config as defaults if not set in request - log!("Issue bob sell request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "sell", - "base": "ETH", - "rel": "ERC20DEV", - "price": 1, - "volume": 0.1, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!sell: {}", rc.1); - let json: Json = serde_json::from_str(&rc.1).unwrap(); - assert_eq!(json["result"]["conf_settings"]["base_confs"], Json::from(1)); - assert_eq!(json["result"]["conf_settings"]["base_nota"], Json::from(false)); - assert_eq!(json["result"]["conf_settings"]["rel_confs"], Json::from(2)); - assert_eq!(json["result"]["conf_settings"]["rel_nota"], Json::from(false)); + let mycoin1_eth = response + .result + .iter() + .find(|pair_depth| pair_depth.pair.0 == "MYCOIN1" && pair_depth.pair.1 == "ETH") + .unwrap(); + assert_eq!(0, mycoin1_eth.depth.asks); + assert_eq!(0, mycoin1_eth.depth.bids); } #[test] -fn test_my_orders_response_format() { - let privkey = random_secp256k1_secret(); - generate_utxo_coin_with_privkey("MYCOIN1", 10000.into(), privkey); - generate_utxo_coin_with_privkey("MYCOIN", 10000.into(), privkey); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); - - let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("MM log path: {}", mm.log_path.display()); - - // Enable coins - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); - - log!("Issue bob buy request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": 0.1, - "base_confs": 5, - "base_nota": true, - "rel_confs": 4, - "rel_nota": false, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - - log!("Issue bob setprice request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": 1, - "volume": 0.1, - "base_confs": 5, - "base_nota": true, - "rel_confs": 4, - "rel_nota": false, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - log!("Issue bob my_orders request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "my_orders", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!my_orders: {}", rc.1); +fn test_orderbook_depth() { + let bob_priv_key = random_secp256k1_secret(); + let alice_priv_key = random_secp256k1_secret(); + let swap_contract = swap_contract_checksum(); - let _: MyOrdersRpcResult = serde_json::from_str(&rc.1).unwrap(); -} + // Fill bob's addresses with coins. + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), bob_priv_key); + generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), bob_priv_key); + fill_eth_erc20_with_private_key(bob_priv_key); -#[test] -fn test_my_orders_after_matched() { - let bob_coin = erc20_coin_with_random_privkey(swap_contract()); - let alice_coin = erc20_coin_with_random_privkey(swap_contract()); + // Fill alice's addresses with coins. + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), alice_priv_key); + generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), alice_priv_key); + fill_eth_erc20_with_private_key(alice_priv_key); - let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); + let coins = json!([ + mycoin_conf(1000), + mycoin1_conf(1000), + eth_dev_conf(), + erc20_dev_conf(&erc20_contract_checksum()) + ]); - let bob_conf = Mm2TestConf::seednode(&bob_coin.display_priv_key().unwrap(), &coins); + let bob_conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(bob_priv_key)), &coins); let mut mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); let (_bob_dump_log, _bob_dump_dashboard) = mm_bob.mm_dump(); log!("Bob log path: {}", mm_bob.log_path.display()); - let alice_conf = Mm2TestConf::light_node( - &alice_coin.display_priv_key().unwrap(), - &coins, - &[&mm_bob.ip.to_string()], - ); - let mut mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); - - let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); - log!("Alice log path: {}", mm_alice.log_path.display()); - - let swap_contract = format!("0x{}", hex::encode(swap_contract())); + // Enable all the coins for bob + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); dbg!(block_on(enable_eth_coin( &mm_bob, "ETH", @@ -5076,7 +370,6 @@ fn test_my_orders_after_matched() { None, false ))); - dbg!(block_on(enable_eth_coin( &mm_bob, "ERC20DEV", @@ -5086,472 +379,428 @@ fn test_my_orders_after_matched() { false ))); - dbg!(block_on(enable_eth_coin( - &mm_alice, - "ETH", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); + // issue sell request on Bob side by setting base/rel price + log!("Issue bob sell requests"); + let bob_orders = [ + // (base, rel, price, volume, min_volume) + ("MYCOIN", "MYCOIN1", "0.9", "0.9", None), + ("MYCOIN", "MYCOIN1", "0.8", "0.9", None), + ("MYCOIN", "MYCOIN1", "0.7", "0.9", Some("0.9")), + ("MYCOIN", "ETH", "0.8", "0.9", None), + ("MYCOIN1", "MYCOIN", "0.8", "0.9", None), + ("MYCOIN1", "MYCOIN", "0.9", "0.9", None), + ("ETH", "MYCOIN", "0.8", "0.9", None), + ]; + for (base, rel, price, volume, min_volume) in bob_orders.iter() { + let rc = block_on(mm_bob.rpc(&json! ({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": base, + "rel": rel, + "price": price, + "volume": volume, + "min_volume": min_volume.unwrap_or("0.00777"), + "cancel_previous": false, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + } - dbg!(block_on(enable_eth_coin( - &mm_alice, - "ERC20DEV", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); + let alice_conf = Mm2TestConf::light_node( + &format!("0x{}", hex::encode(alice_priv_key)), + &coins, + &[&mm_bob.ip.to_string()], + ); + let mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); - let rc = block_on(mm_bob.rpc(&json! ({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "ETH", - "rel": "ERC20DEV", - "price": 1, - "volume": 0.000001, - }))) + let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); + log!("Alice log path: {}", mm_alice.log_path.display()); + + block_on(mm_bob.wait_for_log(22., |log| { + log.contains("DEBUG Handling IncludedTorelaysMesh message for peer") + })) .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); + request_and_check_orderbook_depth(&mm_alice); + // request MYCOIN/MYCOIN1 orderbook to subscribe Alice let rc = block_on(mm_alice.rpc(&json! ({ "userpass": mm_alice.userpass, - "method": "buy", - "base": "ETH", - "rel": "ERC20DEV", - "price": 1, - "volume": 0.000001, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", }))) .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - - block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop ETH/ERC20DEV"))).unwrap(); - block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop ETH/ERC20DEV"))).unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - log!("Issue bob my_orders request"); - let rc = block_on(mm_bob.rpc(&json! ({ - "userpass": mm_bob.userpass, - "method": "my_orders", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!my_orders: {}", rc.1); + request_and_check_orderbook_depth(&mm_alice); - let _: MyOrdersRpcResult = serde_json::from_str(&rc.1).unwrap(); block_on(mm_bob.stop()).unwrap(); block_on(mm_alice.stop()).unwrap(); } -#[test] -fn test_update_maker_order_after_matched() { - let bob_coin = erc20_coin_with_random_privkey(swap_contract()); - let alice_coin = erc20_coin_with_random_privkey(swap_contract()); +// ============================================================================= +// Cross-Chain Best Orders Tests +// These tests verify best_orders RPC across ETH and UTXO coins +// ============================================================================= - let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); +fn get_bob_alice() -> (MarketMakerIt, MarketMakerIt) { + let bob_priv_key = random_secp256k1_secret(); + let alice_priv_key = random_secp256k1_secret(); - let bob_conf = Mm2TestConf::seednode(&bob_coin.display_priv_key().unwrap(), &coins); - let mut mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), bob_priv_key); + generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), bob_priv_key); + fill_eth_erc20_with_private_key(bob_priv_key); + + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), alice_priv_key); + generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), alice_priv_key); + fill_eth_erc20_with_private_key(alice_priv_key); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000), eth_dev_conf(),]); + + let bob_conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(bob_priv_key)), &coins); + let mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); let (_bob_dump_log, _bob_dump_dashboard) = mm_bob.mm_dump(); log!("Bob log path: {}", mm_bob.log_path.display()); let alice_conf = Mm2TestConf::light_node( - &alice_coin.display_priv_key().unwrap(), + &format!("0x{}", hex::encode(alice_priv_key)), &coins, &[&mm_bob.ip.to_string()], ); - let mut mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); + let mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); log!("Alice log path: {}", mm_alice.log_path.display()); - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - dbg!(block_on(enable_eth_coin( - &mm_bob, - "ETH", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); - dbg!(block_on(enable_eth_coin( - &mm_bob, - "ERC20DEV", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + let swap_contract = swap_contract_checksum(); dbg!(block_on(enable_eth_coin( - &mm_alice, + &mm_bob, "ETH", &[GETH_RPC_URL], &swap_contract, None, false ))); - dbg!(block_on(enable_eth_coin( &mm_alice, - "ERC20DEV", + "ETH", &[GETH_RPC_URL], &swap_contract, None, false ))); - let rc = block_on(mm_bob.rpc(&json! ({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": "ETH", - "rel": "ERC20DEV", - "price": 1, - "volume": 0.00002, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - let setprice_json: Json = serde_json::from_str(&rc.1).unwrap(); - let uuid: String = serde_json::from_value(setprice_json["result"]["uuid"].clone()).unwrap(); - - let rc = block_on(mm_alice.rpc(&json! ({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": "ETH", - "rel": "ERC20DEV", - "price": 1, - "volume": 0.00001, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - - block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop ETH/ERC20DEV"))).unwrap(); - block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop ETH/ERC20DEV"))).unwrap(); - - log!("Issue bob update maker order request that should fail because new volume is less than reserved amount"); - let update_maker_order = block_on(mm_bob.rpc(&json! ({ - "userpass": mm_bob.userpass, - "method": "update_maker_order", - "uuid": uuid, - "volume_delta": -0.00002, - }))) - .unwrap(); - assert!( - !update_maker_order.0.is_success(), - "update_maker_order success, but should be error {}", - update_maker_order.1 - ); - - log!("Issue another bob update maker order request"); - let update_maker_order = block_on(mm_bob.rpc(&json! ({ - "userpass": mm_bob.userpass, - "method": "update_maker_order", - "uuid": uuid, - "volume_delta": 0.00001, - }))) - .unwrap(); - assert!( - update_maker_order.0.is_success(), - "!update_maker_order: {}", - update_maker_order.1 - ); - let update_maker_order_json: Json = serde_json::from_str(&update_maker_order.1).unwrap(); - log!("{}", update_maker_order.1); - assert_eq!(update_maker_order_json["result"]["max_base_vol"], Json::from("0.00003")); - - log!("Issue bob my_orders request"); - let rc = block_on(mm_bob.rpc(&json! ({ - "userpass": mm_bob.userpass, - "method": "my_orders", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!my_orders: {}", rc.1); - - let _: MyOrdersRpcResult = serde_json::from_str(&rc.1).unwrap(); - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); -} - -#[test] -fn test_buy_min_volume() { - let privkey = random_secp256k1_secret(); - generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), privkey); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); - - let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("MM log path: {}", mm.log_path.display()); - - // Enable coins - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); - - let min_volume: BigDecimal = "0.1".parse().unwrap(); - log!("Issue bob MYCOIN/MYCOIN1 buy request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "buy", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": "2", - "volume": "1", - "min_volume": min_volume, - "order_type": { - "type": "GoodTillCancelled" - }, - "timeout": 2, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!sell: {}", rc.1); - let response: BuyOrSellRpcResult = serde_json::from_str(&rc.1).unwrap(); - assert_eq!(min_volume, response.result.min_volume); - - log!("Wait for 4 seconds for Bob order to be converted to maker"); - block_on(Timer::sleep(4.)); - - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "my_orders", - }))) - .unwrap(); - assert!(rc.0.is_success(), "!my_orders: {}", rc.1); - let my_orders: MyOrdersRpcResult = serde_json::from_str(&rc.1).unwrap(); - assert_eq!( - 1, - my_orders.result.maker_orders.len(), - "maker_orders must have exactly 1 order" - ); - assert!(my_orders.result.taker_orders.is_empty(), "taker_orders must be empty"); - let maker_order = my_orders.result.maker_orders.get(&response.result.uuid).unwrap(); - - let expected_min_volume: BigDecimal = "0.2".parse().unwrap(); - assert_eq!(expected_min_volume, maker_order.min_base_vol); + (mm_bob, mm_alice) } #[test] -fn test_sell_min_volume() { - let privkey = random_secp256k1_secret(); - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); - - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); - - let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); - - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("MM log path: {}", mm.log_path.display()); +fn test_best_orders() { + let (mut mm_bob, mm_alice) = get_bob_alice(); - // Enable coins - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + // issue sell request on Bob side by setting base/rel price + log!("Issue bob sell requests"); - let min_volume: BigDecimal = "0.1".parse().unwrap(); - log!("Issue bob MYCOIN/MYCOIN1 sell request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "sell", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": "1", - "volume": "1", - "min_volume": min_volume, - "order_type": { - "type": "GoodTillCancelled" - }, - "timeout": 2, - }))) - .unwrap(); - assert!(rc.0.is_success(), "!sell: {}", rc.1); - let rc_json: Json = serde_json::from_str(&rc.1).unwrap(); - let uuid: String = serde_json::from_value(rc_json["result"]["uuid"].clone()).unwrap(); - let min_volume_response: BigDecimal = serde_json::from_value(rc_json["result"]["min_volume"].clone()).unwrap(); - assert_eq!(min_volume, min_volume_response); + let bob_orders = [ + // (base, rel, price, volume, min_volume) + ("MYCOIN", "MYCOIN1", "0.9", "0.9", None), + ("MYCOIN", "MYCOIN1", "0.8", "0.9", None), + ("MYCOIN", "MYCOIN1", "0.7", "0.9", Some("0.9")), + ("MYCOIN", "ETH", "0.8", "0.9", None), + ("MYCOIN1", "MYCOIN", "0.8", "0.9", None), + ("MYCOIN1", "MYCOIN", "0.9", "0.9", None), + ("ETH", "MYCOIN", "0.8", "0.9", None), + ("MYCOIN1", "ETH", "0.8", "0.8", None), + ("MYCOIN1", "ETH", "0.7", "0.8", Some("0.8")), + ]; + for (base, rel, price, volume, min_volume) in bob_orders.iter() { + let rc = block_on(mm_bob.rpc(&json! ({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": base, + "rel": rel, + "price": price, + "volume": volume, + "min_volume": min_volume.unwrap_or("0.00777"), + "cancel_previous": false, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + } - log!("Wait for 4 seconds for Bob order to be converted to maker"); - block_on(Timer::sleep(4.)); + block_on(mm_bob.wait_for_log(22., |log| { + log.contains("DEBUG Handling IncludedTorelaysMesh message for peer") + })) + .unwrap(); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "my_orders", + let rc = block_on(mm_alice.rpc(&json! ({ + "userpass": mm_alice.userpass, + "method": "best_orders", + "coin": "MYCOIN", + "action": "buy", + "volume": "0.1", }))) .unwrap(); - assert!(rc.0.is_success(), "!my_orders: {}", rc.1); - let my_orders: Json = serde_json::from_str(&rc.1).unwrap(); - let my_maker_orders: HashMap = - serde_json::from_value(my_orders["result"]["maker_orders"].clone()).unwrap(); - let my_taker_orders: HashMap = - serde_json::from_value(my_orders["result"]["taker_orders"].clone()).unwrap(); - assert_eq!(1, my_maker_orders.len(), "maker_orders must have exactly 1 order"); - assert!(my_taker_orders.is_empty(), "taker_orders must be empty"); - let maker_order = my_maker_orders.get(&uuid).unwrap(); - let min_volume_maker: BigDecimal = serde_json::from_value(maker_order["min_base_vol"].clone()).unwrap(); - assert_eq!(min_volume, min_volume_maker); -} + assert!(rc.0.is_success(), "!best_orders: {}", rc.1); + let response: BestOrdersResponse = serde_json::from_str(&rc.1).unwrap(); + let best_mycoin1_orders = response.result.get("MYCOIN1").unwrap(); + assert_eq!(1, best_mycoin1_orders.len()); + let expected_price: BigDecimal = "0.8".parse().unwrap(); + assert_eq!(expected_price, best_mycoin1_orders[0].price); -#[test] -fn test_setprice_min_volume_dust() { - let privkey = random_secp256k1_secret(); - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); + let rc = block_on(mm_alice.rpc(&json! ({ + "userpass": mm_alice.userpass, + "method": "best_orders", + "coin": "MYCOIN", + "action": "buy", + "volume": "1.7", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!best_orders: {}", rc.1); + let response: BestOrdersResponse = serde_json::from_str(&rc.1).unwrap(); + // MYCOIN1 + let best_mycoin1_orders = response.result.get("MYCOIN1").unwrap(); + let expected_price: BigDecimal = "0.7".parse().unwrap(); + let bob_mycoin1_addr = block_on(my_balance(&mm_bob, "MYCOIN1")).address; + // let bob_mycoin1_addr = mm_bob.display_address("MYCOIN1").unwrap(); + assert_eq!(expected_price, best_mycoin1_orders[0].price); + assert_eq!(bob_mycoin1_addr, best_mycoin1_orders[0].address); + let expected_price: BigDecimal = "0.8".parse().unwrap(); + assert_eq!(expected_price, best_mycoin1_orders[1].price); + assert_eq!(bob_mycoin1_addr, best_mycoin1_orders[1].address); + // ETH + let expected_price: BigDecimal = "0.8".parse().unwrap(); + let best_eth_orders = response.result.get("ETH").unwrap(); + assert_eq!(expected_price, best_eth_orders[0].price); - let coins = json! ([ - {"coin":"MYCOIN","asset":"MYCOIN","txversion":4,"overwintered":1,"txfee":1000,"dust":10000000,"protocol":{"type":"UTXO"}}, - mycoin1_conf(1000), - ]); + let rc = block_on(mm_alice.rpc(&json! ({ + "userpass": mm_alice.userpass, + "method": "best_orders", + "coin": "MYCOIN", + "action": "sell", + "volume": "0.1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!best_orders: {}", rc.1); + let response: BestOrdersResponse = serde_json::from_str(&rc.1).unwrap(); - let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + let expected_price: BigDecimal = "1.25".parse().unwrap(); - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("MM log path: {}", mm.log_path.display()); + let best_mycoin1_orders = response.result.get("MYCOIN1").unwrap(); + assert_eq!(expected_price, best_mycoin1_orders[0].price); + assert_eq!(1, best_mycoin1_orders.len()); - // Enable coins - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + let best_eth_orders = response.result.get("ETH").unwrap(); + assert_eq!(expected_price, best_eth_orders[0].price); - log!("Issue bob MYCOIN/MYCOIN1 sell request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": "1", - "volume": "1", + let rc = block_on(mm_alice.rpc(&json! ({ + "userpass": mm_alice.userpass, + "method": "best_orders", + "coin": "ETH", + "action": "sell", + "volume": "0.1", }))) .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - let response: SetPriceResponse = serde_json::from_str(&rc.1).unwrap(); - let expected_min = BigDecimal::from(1); - assert_eq!(expected_min, response.result.min_base_vol); + assert!(rc.0.is_success(), "!best_orders: {}", rc.1); + let response: BestOrdersResponse = serde_json::from_str(&rc.1).unwrap(); - log!("Issue bob MYCOIN/MYCOIN1 sell request less than dust"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "setprice", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": "1", - // Less than dust, should fial - "volume": 0.01, - }))) - .unwrap(); - assert!(!rc.0.is_success(), "!setprice: {}", rc.1); -} + let expected_price: BigDecimal = "1.25".parse().unwrap(); -#[test] -fn test_sell_min_volume_dust() { - let privkey = random_secp256k1_secret(); - generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); + let best_mycoin1_orders = response.result.get("MYCOIN1").unwrap(); + assert_eq!(expected_price, best_mycoin1_orders[0].price); + assert_eq!("MYCOIN1", best_mycoin1_orders[0].coin); + assert_eq!(1, best_mycoin1_orders.len()); - let coins = json! ([ - {"coin":"MYCOIN","asset":"MYCOIN","txversion":4,"overwintered":1,"txfee":1000,"dust":10000000,"protocol":{"type":"UTXO"}}, - mycoin1_conf(1000), - ]); + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} - let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); - let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); +#[test] +fn test_best_orders_v2_by_number() { + let (mut mm_bob, mm_alice) = get_bob_alice(); - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("MM log path: {}", mm.log_path.display()); + // issue sell request on Bob side by setting base/rel price + log!("Issue bob sell requests"); - // Enable coins - log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); - log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + let bob_orders = [ + // (base, rel, price, volume, min_volume) + ("MYCOIN", "MYCOIN1", "0.9", "0.9", None), + ("MYCOIN", "MYCOIN1", "0.8", "0.9", None), + ("MYCOIN", "MYCOIN1", "0.7", "0.9", Some("0.9")), + ("MYCOIN", "ETH", "0.8", "0.9", None), + ("MYCOIN1", "MYCOIN", "0.8", "0.9", None), + ("MYCOIN1", "MYCOIN", "0.9", "0.9", None), + ("ETH", "MYCOIN", "0.8", "0.9", None), + ("MYCOIN1", "ETH", "0.8", "0.8", None), + ("MYCOIN1", "ETH", "0.7", "0.8", Some("0.8")), + ]; + for (base, rel, price, volume, min_volume) in bob_orders.iter() { + let rc = block_on(mm_bob.rpc(&json! ({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": base, + "rel": rel, + "price": price, + "volume": volume, + "min_volume": min_volume.unwrap_or("0.00777"), + "cancel_previous": false, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + } - log!("Issue bob MYCOIN/MYCOIN1 sell request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "sell", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": "1", - "volume": "1", - "order_type": { - "type": "FillOrKill" - } - }))) + block_on(mm_bob.wait_for_log(22., |log| { + log.contains("DEBUG Handling IncludedTorelaysMesh message for peer") + })) .unwrap(); - assert!(rc.0.is_success(), "!sell: {}", rc.1); - let response: BuyOrSellRpcResult = serde_json::from_str(&rc.1).unwrap(); - let expected_min = BigDecimal::from(1); - assert_eq!(response.result.min_volume, expected_min); - log!("Issue bob MYCOIN/MYCOIN1 sell request"); - let rc = block_on(mm.rpc(&json! ({ - "userpass": mm.userpass, - "method": "sell", - "base": "MYCOIN", - "rel": "MYCOIN1", - "price": "1", - // Less than dust - "volume": 0.01, - "order_type": { - "type": "FillOrKill" - } - }))) - .unwrap(); - assert!(!rc.0.is_success(), "!sell: {}", rc.1); + let response = block_on(best_orders_v2_by_number(&mm_alice, "MYCOIN", "buy", 1, false)); + log!("response {response:?}"); + let best_mycoin1_orders = response.result.orders.get("MYCOIN1").unwrap(); + log!("Best MYCOIN1 orders when buy MYCOIN {:?}", [best_mycoin1_orders]); + assert_eq!(1, best_mycoin1_orders.len()); + let expected_price: BigDecimal = "0.7".parse().unwrap(); + assert_eq!(expected_price, best_mycoin1_orders[0].price.decimal); + + let response = block_on(best_orders_v2_by_number(&mm_alice, "MYCOIN", "buy", 2, false)); + log!("response {response:?}"); + let best_mycoin1_orders = response.result.orders.get("MYCOIN1").unwrap(); + log!("Best MYCOIN1 orders when buy MYCOIN {:?}", [best_mycoin1_orders]); + assert_eq!(2, best_mycoin1_orders.len()); + let expected_price: BigDecimal = "0.7".parse().unwrap(); + assert_eq!(expected_price, best_mycoin1_orders[0].price.decimal); + let expected_price: BigDecimal = "0.8".parse().unwrap(); + assert_eq!(expected_price, best_mycoin1_orders[1].price.decimal); + + let response = block_on(best_orders_v2_by_number(&mm_alice, "MYCOIN", "sell", 1, false)); + log!("response {response:?}"); + let expected_price: BigDecimal = "1.25".parse().unwrap(); + let best_mycoin1_orders = response.result.orders.get("MYCOIN1").unwrap(); + log!("Best MYCOIN1 orders when sell MYCOIN {:?}", [best_mycoin1_orders]); + assert_eq!(1, best_mycoin1_orders.len()); + assert_eq!(expected_price, best_mycoin1_orders[0].price.decimal); + let best_eth_orders = response.result.orders.get("ETH").unwrap(); + log!("Best ETH orders when sell MYCOIN {:?}", [best_eth_orders]); + assert_eq!(1, best_eth_orders.len()); + assert_eq!(expected_price, best_eth_orders[0].price.decimal); + + let response = block_on(best_orders_v2_by_number(&mm_alice, "ETH", "sell", 1, false)); + log!("response {response:?}"); + let best_mycoin_orders = response.result.orders.get("MYCOIN").unwrap(); + log!("Best MYCOIN orders when sell ETH {:?}", [best_mycoin_orders]); + assert_eq!(1, best_mycoin_orders.len()); + let expected_price: BigDecimal = "1.25".parse().unwrap(); + assert_eq!(expected_price, best_mycoin_orders[0].price.decimal); + + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); } -fn request_and_check_orderbook_depth(mm_alice: &MarketMakerIt) { - let rc = block_on(mm_alice.rpc(&json! ({ - "userpass": mm_alice.userpass, - "method": "orderbook_depth", - "pairs": [("MYCOIN", "MYCOIN1"), ("MYCOIN", "ETH"), ("MYCOIN1", "ETH")], - }))) - .unwrap(); - assert!(rc.0.is_success(), "!orderbook_depth: {}", rc.1); - let response: OrderbookDepthResponse = serde_json::from_str(&rc.1).unwrap(); - let mycoin_mycoin1 = response - .result - .iter() - .find(|pair_depth| pair_depth.pair.0 == "MYCOIN" && pair_depth.pair.1 == "MYCOIN1") - .unwrap(); - assert_eq!(3, mycoin_mycoin1.depth.asks); - assert_eq!(2, mycoin_mycoin1.depth.bids); +#[test] +fn test_best_orders_v2_by_volume() { + let (mut mm_bob, mm_alice) = get_bob_alice(); - let mycoin_eth = response - .result - .iter() - .find(|pair_depth| pair_depth.pair.0 == "MYCOIN" && pair_depth.pair.1 == "ETH") - .unwrap(); - assert_eq!(1, mycoin_eth.depth.asks); - assert_eq!(1, mycoin_eth.depth.bids); + // issue sell request on Bob side by setting base/rel price + log!("Issue bob sell requests"); - let mycoin1_eth = response - .result - .iter() - .find(|pair_depth| pair_depth.pair.0 == "MYCOIN1" && pair_depth.pair.1 == "ETH") + let bob_orders = [ + // (base, rel, price, volume, min_volume) + ("MYCOIN", "MYCOIN1", "0.9", "0.9", None), + ("MYCOIN", "MYCOIN1", "0.8", "0.9", None), + ("MYCOIN", "MYCOIN1", "0.7", "0.9", Some("0.9")), + ("MYCOIN", "ETH", "0.8", "0.9", None), + ("MYCOIN1", "MYCOIN", "0.8", "0.9", None), + ("MYCOIN1", "MYCOIN", "0.9", "0.9", None), + ("ETH", "MYCOIN", "0.8", "0.9", None), + ("MYCOIN1", "ETH", "0.8", "0.8", None), + ("MYCOIN1", "ETH", "0.7", "0.8", Some("0.8")), + ]; + for (base, rel, price, volume, min_volume) in bob_orders.iter() { + let rc = block_on(mm_bob.rpc(&json! ({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": base, + "rel": rel, + "price": price, + "volume": volume, + "min_volume": min_volume.unwrap_or("0.00777"), + "cancel_previous": false, + }))) .unwrap(); - assert_eq!(0, mycoin1_eth.depth.asks); - assert_eq!(0, mycoin1_eth.depth.bids); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + } + + block_on(mm_bob.wait_for_log(22., |log| { + log.contains("DEBUG Handling IncludedTorelaysMesh message for peer") + })) + .unwrap(); + + let response = block_on(best_orders_v2(&mm_alice, "MYCOIN", "buy", "1.7")); + log!("response {response:?}"); + // MYCOIN1 + let best_mycoin1_orders = response.result.orders.get("MYCOIN1").unwrap(); + log!("Best MYCOIN1 orders when buy MYCOIN {:?}", [best_mycoin1_orders]); + let expected_price: BigDecimal = "0.7".parse().unwrap(); + assert_eq!(expected_price, best_mycoin1_orders[0].price.decimal); + let expected_price: BigDecimal = "0.8".parse().unwrap(); + assert_eq!(expected_price, best_mycoin1_orders[1].price.decimal); + // ETH + let expected_price: BigDecimal = "0.8".parse().unwrap(); + let best_eth_orders = response.result.orders.get("ETH").unwrap(); + log!("Best ETH orders when buy MYCOIN {:?}", [best_eth_orders]); + assert_eq!(expected_price, best_eth_orders[0].price.decimal); + + let response = block_on(best_orders_v2(&mm_alice, "MYCOIN", "sell", "0.1")); + log!("response {response:?}"); + let expected_price: BigDecimal = "1.25".parse().unwrap(); + let best_mycoin1_orders = response.result.orders.get("MYCOIN1").unwrap(); + log!("Best MYCOIN1 orders when sell MYCOIN {:?}", [best_mycoin1_orders]); + assert_eq!(expected_price, best_mycoin1_orders[0].price.decimal); + assert_eq!(1, best_mycoin1_orders.len()); + let best_eth_orders = response.result.orders.get("ETH").unwrap(); + log!("Best ETH orders when sell MYCOIN {:?}", [best_mycoin1_orders]); + assert_eq!(expected_price, best_eth_orders[0].price.decimal); + + let response = block_on(best_orders_v2(&mm_alice, "ETH", "sell", "0.1")); + log!("response {response:?}"); + let expected_price: BigDecimal = "1.25".parse().unwrap(); + let best_mycoin1_orders = response.result.orders.get("MYCOIN1").unwrap(); + log!("Best MYCOIN1 orders when sell ETH {:?}", [best_mycoin1_orders]); + assert_eq!(expected_price, best_mycoin1_orders[0].price.decimal); + assert_eq!("MYCOIN1", best_mycoin1_orders[0].coin); + assert_eq!(1, best_mycoin1_orders.len()); + + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); } #[test] -fn test_orderbook_depth() { +fn test_best_orders_filter_response() { + // alice defined MYCOIN1 as "wallet_only" in config + let alice_coins = json!([ + mycoin_conf(1000), + {"coin":"MYCOIN1","asset":"MYCOIN1","rpcport":11608,"wallet_only": true,"txversion":4,"overwintered":1,"protocol":{"type":"UTXO"}}, + eth_dev_conf(), + ]); + let bob_priv_key = random_secp256k1_secret(); let alice_priv_key = random_secp256k1_secret(); - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - // Fill bob's addresses with coins. generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), bob_priv_key); generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), bob_priv_key); fill_eth_erc20_with_private_key(bob_priv_key); - // Fill alice's addresses with coins. generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), alice_priv_key); generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), alice_priv_key); fill_eth_erc20_with_private_key(alice_priv_key); - let coins = json!([ - mycoin_conf(1000), - mycoin1_conf(1000), - eth_dev_conf(), - erc20_dev_conf(&erc20_contract_checksum()) - ]); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000), eth_dev_conf(),]); let bob_conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(bob_priv_key)), &coins); let mut mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); @@ -5559,9 +808,9 @@ fn test_orderbook_depth() { let (_bob_dump_log, _bob_dump_dashboard) = mm_bob.mm_dump(); log!("Bob log path: {}", mm_bob.log_path.display()); - // Enable all the coins for bob log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + let swap_contract = swap_contract_checksum(); dbg!(block_on(enable_eth_coin( &mm_bob, "ETH", @@ -5570,17 +819,10 @@ fn test_orderbook_depth() { None, false ))); - dbg!(block_on(enable_eth_coin( - &mm_bob, - "ERC20DEV", - &[GETH_RPC_URL], - &swap_contract, - None, - false - ))); // issue sell request on Bob side by setting base/rel price log!("Issue bob sell requests"); + let bob_orders = [ // (base, rel, price, volume, min_volume) ("MYCOIN", "MYCOIN1", "0.9", "0.9", None), @@ -5590,6 +832,8 @@ fn test_orderbook_depth() { ("MYCOIN1", "MYCOIN", "0.8", "0.9", None), ("MYCOIN1", "MYCOIN", "0.9", "0.9", None), ("ETH", "MYCOIN", "0.8", "0.9", None), + ("MYCOIN1", "ETH", "0.8", "0.8", None), + ("MYCOIN1", "ETH", "0.7", "0.8", Some("0.8")), ]; for (base, rel, price, volume, min_volume) in bob_orders.iter() { let rc = block_on(mm_bob.rpc(&json! ({ @@ -5608,7 +852,7 @@ fn test_orderbook_depth() { let alice_conf = Mm2TestConf::light_node( &format!("0x{}", hex::encode(alice_priv_key)), - &coins, + &alice_coins, &[&mm_bob.ip.to_string()], ); let mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); @@ -5621,173 +865,22 @@ fn test_orderbook_depth() { })) .unwrap(); - request_and_check_orderbook_depth(&mm_alice); - // request MYCOIN/MYCOIN1 orderbook to subscribe Alice let rc = block_on(mm_alice.rpc(&json! ({ "userpass": mm_alice.userpass, - "method": "orderbook", - "base": "MYCOIN", - "rel": "MYCOIN1", + "method": "best_orders", + "coin": "MYCOIN", + "action": "buy", + "volume": "0.1", }))) .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - request_and_check_orderbook_depth(&mm_alice); + assert!(rc.0.is_success(), "!best_orders: {}", rc.1); + let response: BestOrdersResponse = serde_json::from_str(&rc.1).unwrap(); + let empty_vec = Vec::new(); + let best_mycoin1_orders = response.result.get("MYCOIN1").unwrap_or(&empty_vec); + assert_eq!(0, best_mycoin1_orders.len()); + let best_eth_orders = response.result.get("ETH").unwrap(); + assert_eq!(1, best_eth_orders.len()); block_on(mm_bob.stop()).unwrap(); block_on(mm_alice.stop()).unwrap(); } - -#[test] -fn test_approve_erc20() { - let privkey = random_secp256k1_secret(); - fill_eth_erc20_with_private_key(privkey); - - let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); - let mm = MarketMakerIt::start( - Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins).conf, - DEFAULT_RPC_PASSWORD.to_string(), - None, - ) - .unwrap(); - - let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); - log!("Node log path: {}", mm.log_path.display()); - - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - let _eth_enable = block_on(enable_eth_coin( - &mm, - "ETH", - &[GETH_RPC_URL], - &swap_contract, - None, - false, - )); - let _erc20_enable = block_on(enable_eth_coin( - &mm, - "ERC20DEV", - &[GETH_RPC_URL], - &swap_contract, - None, - false, - )); - - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "method":"approve_token", - "mmrpc":"2.0", - "id": 0, - "params":{ - "coin": "ERC20DEV", - "spender": swap_contract, - "amount": BigDecimal::from_str("11.0").unwrap(), - } - }))) - .unwrap(); - assert!(rc.0.is_success(), "approve_token error: {}", rc.1); - let res = serde_json::from_str::(&rc.1).unwrap(); - assert!( - hex::decode(str_strip_0x!(res["result"].as_str().unwrap())).is_ok(), - "approve_token result incorrect" - ); - thread::sleep(Duration::from_secs(5)); - let rc = block_on(mm.rpc(&json!({ - "userpass": mm.userpass, - "method":"get_token_allowance", - "mmrpc":"2.0", - "id": 0, - "params":{ - "coin": "ERC20DEV", - "spender": swap_contract, - } - }))) - .unwrap(); - assert!(rc.0.is_success(), "get_token_allowance error: {}", rc.1); - let res = serde_json::from_str::(&rc.1).unwrap(); - assert_eq!( - BigDecimal::from_str(res["result"].as_str().unwrap()).unwrap(), - BigDecimal::from_str("11.0").unwrap(), - "get_token_allowance result incorrect" - ); - - block_on(mm.stop()).unwrap(); -} - -#[test] -fn test_peer_time_sync_validation() { - let timeoffset_tolerable = TryInto::::try_into(MAX_TIME_GAP_FOR_CONNECTED_PEER).unwrap() - 1; - let timeoffset_too_big = TryInto::::try_into(MAX_TIME_GAP_FOR_CONNECTED_PEER).unwrap() + 1; - - let start_peers_with_time_offset = |offset: i64| -> (Json, Json) { - let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 10.into()); - let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 10.into()); - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let bob_conf = Mm2TestConf::seednode(&hex::encode(bob_priv_key), &coins); - let mut mm_bob = block_on(MarketMakerIt::start_with_envs( - bob_conf.conf, - bob_conf.rpc_password, - None, - &[], - )) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); - block_on(mm_bob.wait_for_log(22., |log| log.contains(">>>>>>>>> DEX stats "))).unwrap(); - let alice_conf = - Mm2TestConf::light_node(&hex::encode(alice_priv_key), &coins, &[mm_bob.ip.to_string().as_str()]); - let mut mm_alice = block_on(MarketMakerIt::start_with_envs( - alice_conf.conf, - alice_conf.rpc_password, - None, - &[("TEST_TIMESTAMP_OFFSET", offset.to_string().as_str())], - )) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); - block_on(mm_alice.wait_for_log(22., |log| log.contains(">>>>>>>>> DEX stats "))).unwrap(); - - let res_bob = block_on(mm_bob.rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "get_directly_connected_peers", - }))) - .unwrap(); - assert!(res_bob.0.is_success(), "!get_directly_connected_peers: {}", res_bob.1); - let bob_peers = serde_json::from_str::(&res_bob.1).unwrap(); - - let res_alice = block_on(mm_alice.rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "get_directly_connected_peers", - }))) - .unwrap(); - assert!( - res_alice.0.is_success(), - "!get_directly_connected_peers: {}", - res_alice.1 - ); - let alice_peers = serde_json::from_str::(&res_alice.1).unwrap(); - - block_on(mm_bob.stop()).unwrap(); - block_on(mm_alice.stop()).unwrap(); - (bob_peers, alice_peers) - }; - - // check with small time offset: - let (bob_peers, alice_peers) = start_peers_with_time_offset(timeoffset_tolerable); - assert!( - bob_peers["result"].as_object().unwrap().len() == 1, - "bob must have one peer" - ); - assert!( - alice_peers["result"].as_object().unwrap().len() == 1, - "alice must have one peer" - ); - - // check with too big time offset: - let (bob_peers, alice_peers) = start_peers_with_time_offset(timeoffset_too_big); - assert!( - bob_peers["result"].as_object().unwrap().is_empty(), - "bob must have no peers" - ); - assert!( - alice_peers["result"].as_object().unwrap().is_empty(), - "alice must have no peers" - ); -} diff --git a/mm2src/mm2_main/tests/docker_tests/eth_docker_tests.rs b/mm2src/mm2_main/tests/docker_tests/eth_docker_tests.rs index 8cc759ffb8..8289bd86e5 100644 --- a/mm2src/mm2_main/tests/docker_tests/eth_docker_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/eth_docker_tests.rs @@ -1,36 +1,35 @@ -use super::docker_tests_common::{ - random_secp256k1_secret, ERC1155_TEST_ABI, ERC721_TEST_ABI, GETH_ACCOUNT, GETH_ERC1155_CONTRACT, - GETH_ERC20_CONTRACT, GETH_ERC721_CONTRACT, GETH_MAKER_SWAP_V2, GETH_NFT_MAKER_SWAP_V2, GETH_NONCE_LOCK, - GETH_RPC_URL, GETH_SWAP_CONTRACT, GETH_TAKER_SWAP_V2, GETH_WATCHERS_SWAP_CONTRACT, GETH_WEB3, MM_CTX, MM_CTX1, -}; -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -use super::docker_tests_common::{ - SEPOLIA_ERC20_CONTRACT, SEPOLIA_ETOMIC_MAKER_NFT_SWAP_V2, SEPOLIA_MAKER_SWAP_V2, SEPOLIA_NONCE_LOCK, - SEPOLIA_RPC_URL, SEPOLIA_TAKER_SWAP_V2, SEPOLIA_TESTS_LOCK, SEPOLIA_WEB3, +use super::helpers::env::random_secp256k1_secret; +use super::helpers::eth::{ + erc20_coin_with_random_privkey, erc20_contract, erc20_contract_checksum, eth_coin_with_random_privkey, + eth_coin_with_random_privkey_using_urls, fill_erc20, fill_eth, geth_account, geth_erc1155_contract, + geth_erc721_contract, geth_maker_swap_v2, geth_nft_maker_swap_v2, geth_taker_swap_v2, geth_usdt_contract, + swap_contract, swap_contract_checksum, usdt_coin_with_random_privkey, GETH_DEV_CHAIN_ID, GETH_NONCE_LOCK, + GETH_RPC_URL, GETH_WEB3, MM_CTX, MM_CTX1, }; use crate::common::Future01CompatExt; use bitcrypto::{dhash160, sha256}; +use coins::eth::erc20::get_erc20_token_info; use coins::eth::gas_limit::ETH_MAX_TRADE_GAS; use coins::eth::v2_activation::{eth_coin_from_conf_and_request_v2, EthActivationV2Request, EthNode}; use coins::eth::{ - checksum_address, eth_coin_from_conf_and_request, ChainSpec, EthCoin, EthCoinType, EthPrivKeyBuildPolicy, - SignedEthTx, SwapV2Contracts, ERC20_ABI, + eth_coin_from_conf_and_request, ChainSpec, EthCoin, EthCoinType, EthPrivKeyBuildPolicy, SignedEthTx, + SwapV2Contracts, }; use coins::hd_wallet::AddrToString; use coins::nft::nft_structs::{Chain, ContractType, NftInfo}; -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] use coins::{ - lp_coinfind, CoinsContext, DexFee, FundingTxSpend, GenTakerFundingSpendArgs, GenTakerPaymentSpendArgs, - MakerCoinSwapOpsV2, MmCoinStruct, RefundFundingSecretArgs, RefundMakerPaymentSecretArgs, - RefundMakerPaymentTimelockArgs, RefundTakerPaymentArgs, SendMakerPaymentArgs, SendTakerFundingArgs, - SpendMakerPaymentArgs, TakerCoinSwapOpsV2, TxPreimageWithSig, ValidateMakerPaymentArgs, ValidateTakerFundingArgs, + lp_coinfind, lp_register_coin, CoinProtocol, CoinWithDerivationMethod, CoinsContext, CommonSwapOpsV2, + ConfirmPaymentInput, Eip1559Ops, FoundSwapTxSpend, MakerNftSwapOpsV2, MarketCoinOps, MmCoinEnum, NftSwapInfo, + ParseCoinAssocTypes, ParseNftAssocTypes, PrivKeyBuildPolicy, RefundNftMakerPaymentArgs, RefundPaymentArgs, + RegisterCoinParams, SearchForSwapTxSpendInput, SendNftMakerPaymentArgs, SendPaymentArgs, SpendNftMakerPaymentArgs, + SpendPaymentArgs, SwapGasFeePolicy, SwapOps, SwapTxTypeWithSecretHash, ToBytes, Transaction, + ValidateNftMakerPaymentArgs, }; use coins::{ - lp_register_coin, CoinProtocol, CoinWithDerivationMethod, CommonSwapOpsV2, ConfirmPaymentInput, DerivationMethod, - Eip1559Ops, FoundSwapTxSpend, MakerNftSwapOpsV2, MarketCoinOps, MmCoinEnum, NftSwapInfo, ParseCoinAssocTypes, - ParseNftAssocTypes, PrivKeyBuildPolicy, RefundNftMakerPaymentArgs, RefundPaymentArgs, RegisterCoinParams, - SearchForSwapTxSpendInput, SendNftMakerPaymentArgs, SendPaymentArgs, SpendNftMakerPaymentArgs, SpendPaymentArgs, - SwapGasFeePolicy, SwapOps, SwapTxTypeWithSecretHash, ToBytes, Transaction, ValidateNftMakerPaymentArgs, + DexFee, FundingTxSpend, GenTakerFundingSpendArgs, GenTakerPaymentSpendArgs, MakerCoinSwapOpsV2, + RefundFundingSecretArgs, RefundMakerPaymentSecretArgs, RefundMakerPaymentTimelockArgs, RefundTakerPaymentArgs, + SendMakerPaymentArgs, SendTakerFundingArgs, SpendMakerPaymentArgs, TakerCoinSwapOpsV2, TxPreimageWithSig, + ValidateMakerPaymentArgs, ValidateTakerFundingArgs, }; use common::{block_on, block_on_f01, now_sec}; use crypto::Secp256k1Secret; @@ -41,118 +40,31 @@ use mm2_test_helpers::for_tests::{ account_balance, active_swaps, check_recent_swaps, coins_needed_for_kickstart, disable_coin, enable_erc20_token_v2, enable_eth_coin_with_tokens_v2, erc20_dev_conf, eth_dev_conf, get_locked_amount, get_new_address, get_token_info, mm_dump, my_balance, my_swap_status, nft_dev_conf, start_swaps, task_enable_eth_with_tokens, - wait_for_swap_finished, MarketMakerIt, Mm2TestConf, SwapV2TestContracts, TestNode, ETH_SEPOLIA_CHAIN_ID, + wait_for_swap_finished, MarketMakerIt, Mm2TestConf, SwapV2TestContracts, TestNode, }; -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -use mm2_test_helpers::for_tests::{eth_sepolia_conf, sepolia_erc20_dev_conf}; use mm2_test_helpers::structs::{ Bip44Chain, EnableCoinBalanceMap, EthWithTokensActivationResult, HDAccountAddressId, TokenInfo, }; use num_traits::FromPrimitive; -use serde_json::Value as Json; -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] +use serde_json::{json, Value as Json}; use std::str::FromStr; use std::thread; use std::time::Duration; use uuid::Uuid; use web3::contract::{Contract, Options}; use web3::ethabi::Token; -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -use web3::types::BlockNumber; -use web3::types::{Address, TransactionRequest, H256}; - -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -const SEPOLIA_MAKER_PRIV: &str = "6e2f3a6223b928a05a3a3622b0c3f3573d03663b704a61a6eb73326de0487928"; -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -const SEPOLIA_TAKER_PRIV: &str = "e0be82dca60ff7e4c6d6db339ac9e1ae249af081dba2110bddd281e711608f16"; +use web3::types::{Address, H256}; + const NFT_ETH: &str = "NFT_ETH"; const ETH: &str = "ETH"; -const GETH_DEV_CHAIN_ID: u64 = 1337; - -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -const ERC20: &str = "ERC20DEV"; - -/// # Safety -/// -/// GETH_ACCOUNT is set once during initialization before tests start -pub fn geth_account() -> Address { - unsafe { GETH_ACCOUNT } -} -/// # Safety -/// -/// GETH_SWAP_CONTRACT is set once during initialization before tests start -pub fn swap_contract() -> Address { - unsafe { GETH_SWAP_CONTRACT } -} -/// # Safety -/// -/// GETH_MAKER_SWAP_V2 is set once during initialization before tests start -pub fn maker_swap_v2() -> Address { - unsafe { GETH_MAKER_SWAP_V2 } -} -/// # Safety -/// -/// GETH_TAKER_SWAP_V2 is set once during initialization before tests start -pub fn taker_swap_v2() -> Address { - unsafe { GETH_TAKER_SWAP_V2 } -} -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -pub fn sepolia_taker_swap_v2() -> Address { - unsafe { SEPOLIA_TAKER_SWAP_V2 } -} -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -pub fn sepolia_maker_swap_v2() -> Address { - unsafe { SEPOLIA_MAKER_SWAP_V2 } -} -/// # Safety -/// -/// GETH_NFT_MAKER_SWAP_V2 is set once during initialization before tests start -pub fn geth_nft_maker_swap_v2() -> Address { - unsafe { GETH_NFT_MAKER_SWAP_V2 } -} -/// # Safety -/// -/// GETH_WATCHERS_SWAP_CONTRACT is set once during initialization before tests start -pub fn watchers_swap_contract() -> Address { - unsafe { GETH_WATCHERS_SWAP_CONTRACT } -} -/// # Safety -/// -/// GETH_ERC20_CONTRACT is set once during initialization before tests start -pub fn erc20_contract() -> Address { - unsafe { GETH_ERC20_CONTRACT } -} -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -pub fn sepolia_erc20_contract() -> Address { - unsafe { SEPOLIA_ERC20_CONTRACT } -} -/// Return ERC20 dev token contract address in checksum format -pub fn erc20_contract_checksum() -> String { - checksum_address(&format!("{:02x}", erc20_contract())) -} -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -pub fn sepolia_erc20_contract_checksum() -> String { - checksum_address(&format!("{:02x}", sepolia_erc20_contract())) -} -/// # Safety -/// -/// GETH_ERC721_CONTRACT is set once during initialization before tests start -pub fn geth_erc721_contract() -> Address { - unsafe { GETH_ERC721_CONTRACT } -} -/// # Safety -/// -/// GETH_ERC1155_CONTRACT is set once during initialization before tests start -pub fn geth_erc1155_contract() -> Address { - unsafe { GETH_ERC1155_CONTRACT } -} -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -/// # Safety -/// -/// SEPOLIA_ETOMIC_MAKER_NFT_SWAP_V2 address is set once during initialization before tests start -pub fn sepolia_etomic_maker_nft() -> Address { - unsafe { SEPOLIA_ETOMIC_MAKER_NFT_SWAP_V2 } -} +const ERC20DEV: &str = "ERC20DEV"; + +/// ERC721_TEST_TOKEN has additional mint function +/// https://github.com/KomodoPlatform/etomic-swap/blob/public-mint-nft-functions/contracts/Erc721Token.sol (see public-mint-nft-functions branch) +const ERC721_TEST_ABI: &str = include_str!("../../../mm2_test_helpers/dummy_files/erc721_test_abi.json"); +/// ERC1155_TEST_TOKEN has additional mint function +/// https://github.com/KomodoPlatform/etomic-swap/blob/public-mint-nft-functions/contracts/Erc1155Token.sol (see public-mint-nft-functions branch) +const ERC1155_TEST_ABI: &str = include_str!("../../../mm2_test_helpers/dummy_files/erc1155_test_abi.json"); fn wait_for_confirmation(tx_hash: H256) { thread::sleep(Duration::from_millis(2000)); @@ -169,40 +81,6 @@ fn wait_for_confirmation(tx_hash: H256) { } } -pub fn fill_eth(to_addr: Address, amount: U256) { - let _guard = GETH_NONCE_LOCK.lock().unwrap(); - let tx_request = TransactionRequest { - from: geth_account(), - to: Some(to_addr), - gas: None, - gas_price: None, - value: Some(amount), - data: None, - nonce: None, - condition: None, - transaction_type: None, - access_list: None, - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - }; - let tx_hash = block_on(GETH_WEB3.eth().send_transaction(tx_request)).unwrap(); - wait_for_confirmation(tx_hash); -} - -fn fill_erc20(to_addr: Address, amount: U256) { - let _guard = GETH_NONCE_LOCK.lock().unwrap(); - let erc20_contract = Contract::from_json(GETH_WEB3.eth(), erc20_contract(), ERC20_ABI.as_bytes()).unwrap(); - - let tx_hash = block_on(erc20_contract.call( - "transfer", - (Token::Address(to_addr), Token::Uint(amount)), - geth_account(), - Options::default(), - )) - .unwrap(); - wait_for_confirmation(tx_hash); -} - fn mint_erc721(to_addr: Address, token_id: U256) { let _guard = GETH_NONCE_LOCK.lock().unwrap(); let erc721_contract = @@ -322,81 +200,6 @@ pub(crate) async fn fill_erc721_info(eth_coin: &EthCoin, token_address: Address, nft_infos.insert(erc721_key, erc721_nft_info); } -/// Creates ETH protocol coin supplied with 100 ETH -pub fn eth_coin_with_random_privkey_using_urls(swap_contract_address: Address, urls: &[&str]) -> EthCoin { - let eth_conf = eth_dev_conf(); - let req = json!({ - "method": "enable", - "coin": "ETH", - "swap_contract_address": swap_contract_address, - "urls": urls, - }); - - let secret = random_secp256k1_secret(); - let eth_coin = block_on(eth_coin_from_conf_and_request( - &MM_CTX, - "ETH", - ð_conf, - &req, - CoinProtocol::ETH { - chain_id: GETH_DEV_CHAIN_ID, - }, - PrivKeyBuildPolicy::IguanaPrivKey(secret), - )) - .unwrap(); - - let my_address = match eth_coin.derivation_method() { - DerivationMethod::SingleAddress(addr) => *addr, - _ => panic!("Expected single address"), - }; - - // 100 ETH - fill_eth(my_address, U256::from(10).pow(U256::from(20))); - - eth_coin -} - -/// Creates ETH protocol coin supplied with 100 ETH, using the default GETH_RPC_URL -pub fn eth_coin_with_random_privkey(swap_contract_address: Address) -> EthCoin { - eth_coin_with_random_privkey_using_urls(swap_contract_address, &[GETH_RPC_URL]) -} - -/// Creates ERC20 protocol coin supplied with 1 ETH and 100 token -pub fn erc20_coin_with_random_privkey(swap_contract_address: Address) -> EthCoin { - let erc20_conf = erc20_dev_conf(&erc20_contract_checksum()); - let req = json!({ - "method": "enable", - "coin": "ERC20DEV", - "swap_contract_address": swap_contract_address, - "urls": [GETH_RPC_URL], - }); - - let erc20_coin = block_on(eth_coin_from_conf_and_request( - &MM_CTX, - "ERC20DEV", - &erc20_conf, - &req, - CoinProtocol::ERC20 { - platform: "ETH".to_string(), - contract_address: checksum_address(&format!("{:02x}", erc20_contract())), - }, - PrivKeyBuildPolicy::IguanaPrivKey(random_secp256k1_secret()), - )) - .unwrap(); - - let my_address = match erc20_coin.derivation_method() { - DerivationMethod::SingleAddress(addr) => *addr, - _ => panic!("Expected single address"), - }; - - // 1 ETH - fill_eth(my_address, U256::from(10).pow(U256::from(18))); - // 100 tokens (it has 8 decimals) - fill_erc20(my_address, U256::from(10000000000u64)); - - erc20_coin -} - #[derive(Clone, Copy, Debug)] pub enum TestNftType { Erc1155 { token_id: u32, amount: u32 }, @@ -414,6 +217,41 @@ fn global_nft_with_random_privkey( nft_ticker: String, platform_ticker: String, ) -> EthCoin { + // Register platform ETH coin in MM_CTX1 if not already registered. + // Required because NFT coins call platform_coin() for get_swap_gas_fee_policy(). + if block_on(lp_coinfind(&MM_CTX1, &platform_ticker)) + .ok() + .flatten() + .is_none() + { + let eth_conf = eth_dev_conf(); + let eth_req = json!({ + "urls": [GETH_RPC_URL], + "swap_contract_address": swap_contract_address, + "swap_v2_contracts": { + "maker_swap_v2_contract": swap_v2_contracts.maker_swap_v2_contract, + "taker_swap_v2_contract": swap_v2_contracts.taker_swap_v2_contract, + "nft_maker_swap_v2_contract": swap_v2_contracts.nft_maker_swap_v2_contract + }, + "fallback_swap_contract": fallback_swap_contract_address + }); + let platform_priv_key_policy = PrivKeyBuildPolicy::IguanaPrivKey(Secp256k1Secret::from([1u8; 32])); + let platform_coin = block_on(eth_coin_from_conf_and_request( + &MM_CTX1, + &platform_ticker, + ð_conf, + ð_req, + CoinProtocol::ETH { + chain_id: GETH_DEV_CHAIN_ID, + }, + platform_priv_key_policy, + )) + .unwrap(); + let coins_ctx = CoinsContext::from_ctx(&MM_CTX1).unwrap(); + // Ignore error if another parallel test already registered the platform + let _ = block_on(coins_ctx.add_platform_with_tokens(platform_coin.into(), vec![], None)); + } + let build_policy = EthPrivKeyBuildPolicy::IguanaPrivKey(random_secp256k1_secret()); let node = EthNode { url: GETH_RPC_URL.to_string(), @@ -422,7 +260,7 @@ fn global_nft_with_random_privkey( let platform_request = EthActivationV2Request { nodes: vec![node], rpc_mode: Default::default(), - swap_contract_address, + swap_contract_address: Some(swap_contract_address), swap_v2_contracts: Some(swap_v2_contracts), fallback_swap_contract: Some(fallback_swap_contract_address), contract_supports_watchers: false, @@ -449,7 +287,8 @@ fn global_nft_with_random_privkey( let coin_type = EthCoinType::Nft { platform: platform_ticker, }; - let global_nft = block_on(coin.set_coin_type(coin_type)); + // NFT coins use ETH decimals (18) + let global_nft = block_on(coin.set_coin_type(coin_type, 18)); let my_address = block_on(coin.my_addr()); fill_eth(my_address, U256::from(10).pow(U256::from(20))); @@ -473,133 +312,6 @@ fn global_nft_with_random_privkey( global_nft } -// Todo: This shouldn't be part of docker tests, move it to a separate module or stop relying on it -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -/// Can be used to generate coin from Sepolia Maker/Taker priv keys. -fn sepolia_coin_from_privkey(ctx: &MmArc, secret: &'static str, ticker: &str, conf: &Json, erc20: bool) -> EthCoin { - let swap_addr = SwapAddresses { - swap_v2_contracts: SwapV2Contracts { - maker_swap_v2_contract: sepolia_maker_swap_v2(), - taker_swap_v2_contract: sepolia_taker_swap_v2(), - nft_maker_swap_v2_contract: sepolia_etomic_maker_nft(), - }, - swap_contract_address: sepolia_taker_swap_v2(), - fallback_swap_contract_address: sepolia_taker_swap_v2(), - }; - - let priv_key = Secp256k1Secret::from(secret); - let build_policy = EthPrivKeyBuildPolicy::IguanaPrivKey(priv_key); - - let node = EthNode { - url: SEPOLIA_RPC_URL.to_string(), - komodo_proxy: false, - }; - let platform_request = EthActivationV2Request { - nodes: vec![node], - rpc_mode: Default::default(), - swap_contract_address: swap_addr.swap_contract_address, - swap_v2_contracts: Some(swap_addr.swap_v2_contracts), - fallback_swap_contract: Some(swap_addr.fallback_swap_contract_address), - contract_supports_watchers: false, - mm2: None, - required_confirmations: None, - priv_key_policy: Default::default(), - enable_params: Default::default(), - path_to_address: Default::default(), - gap_limit: None, - swap_gas_fee_policy: None, - }; - let coin = block_on(eth_coin_from_conf_and_request_v2( - ctx, - ticker, - conf, - platform_request, - build_policy, - ChainSpec::Evm { - chain_id: ETH_SEPOLIA_CHAIN_ID, - }, - )) - .unwrap(); - let coin = if erc20 { - let coin_type = EthCoinType::Erc20 { - platform: ETH.to_string(), - token_addr: sepolia_erc20_contract(), - }; - block_on(coin.set_coin_type(coin_type)) - } else { - coin - }; - - let coins_ctx = CoinsContext::from_ctx(ctx).unwrap(); - let mut coins = block_on(coins_ctx.lock_coins()); - coins.insert( - coin.ticker().into(), - MmCoinStruct::new(MmCoinEnum::EthCoinVariant(coin.clone())), - ); - coin -} - -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -fn get_or_create_sepolia_coin(ctx: &MmArc, priv_key: &'static str, ticker: &str, conf: &Json, erc20: bool) -> EthCoin { - match block_on(lp_coinfind(ctx, ticker)).unwrap() { - None => sepolia_coin_from_privkey(ctx, priv_key, ticker, conf, erc20), - Some(mm_coin) => match mm_coin { - MmCoinEnum::EthCoinVariant(coin) => coin, - _ => panic!("Unexpected coin type found. Expected MmCoinEnum::EthCoin"), - }, - } -} - -/// Fills the private key's public address with ETH and ERC20 tokens -pub fn fill_eth_erc20_with_private_key(priv_key: Secp256k1Secret) { - let eth_conf = eth_dev_conf(); - let req = json!({ - "coin": "ETH", - "urls": [GETH_RPC_URL], - "swap_contract_address": swap_contract(), - }); - - let eth_coin = block_on(eth_coin_from_conf_and_request( - &MM_CTX, - "ETH", - ð_conf, - &req, - CoinProtocol::ETH { - chain_id: GETH_DEV_CHAIN_ID, - }, - PrivKeyBuildPolicy::IguanaPrivKey(priv_key), - )) - .unwrap(); - let my_address = block_on(eth_coin.derivation_method().single_addr_or_err()).unwrap(); - - // 100 ETH - fill_eth(my_address, U256::from(10).pow(U256::from(20))); - - let erc20_conf = erc20_dev_conf(&erc20_contract_checksum()); - let req = json!({ - "method": "enable", - "coin": "ERC20DEV", - "urls": [GETH_RPC_URL], - "swap_contract_address": swap_contract(), - }); - - let _erc20_coin = block_on(eth_coin_from_conf_and_request( - &MM_CTX, - "ERC20DEV", - &erc20_conf, - &req, - CoinProtocol::ERC20 { - platform: "ETH".to_string(), - contract_address: erc20_contract_checksum(), - }, - PrivKeyBuildPolicy::IguanaPrivKey(priv_key), - )) - .unwrap(); - - // 100 tokens (it has 8 decimals) - fill_erc20(my_address, U256::from(10000000000u64)); -} - fn send_and_refund_eth_maker_payment_impl(swap_txfee_policy: SwapGasFeePolicy) { thread::sleep(Duration::from_secs(3)); let eth_coin = eth_coin_with_random_privkey(swap_contract()); @@ -665,7 +377,6 @@ fn send_and_refund_eth_maker_payment_impl(swap_txfee_policy: SwapGasFeePolicy) { search_from_block: 0, swap_contract_address: &Some(swap_contract().as_bytes().into()), swap_unique_data: &[], - watcher_reward: false, }; let search_tx = block_on(eth_coin.search_for_swap_tx_spend_my(search_input)) .unwrap() @@ -752,7 +463,6 @@ fn send_and_spend_eth_maker_payment_impl(swap_txfee_policy: SwapGasFeePolicy) { search_from_block: 0, swap_contract_address: &Some(swap_contract().as_bytes().into()), swap_unique_data: &[], - watcher_reward: false, }; let search_tx = block_on(maker_eth_coin.search_for_swap_tx_spend_my(search_input)) .unwrap() @@ -838,7 +548,6 @@ fn send_and_refund_erc20_maker_payment_impl(swap_txfee_policy: SwapGasFeePolicy) search_from_block: 0, swap_contract_address: &Some(swap_contract().as_bytes().into()), swap_unique_data: &[], - watcher_reward: false, }; let search_tx = block_on(erc20_coin.search_for_swap_tx_spend_my(search_input)) .unwrap() @@ -926,7 +635,6 @@ fn send_and_spend_erc20_maker_payment_impl(swap_txfee_policy: SwapGasFeePolicy) search_from_block: 0, swap_contract_address: &Some(swap_contract().as_bytes().into()), swap_unique_data: &[], - watcher_reward: false, }; let search_tx = block_on(maker_erc20_coin.search_for_swap_tx_spend_my(search_input)) .unwrap() @@ -946,33 +654,6 @@ fn send_and_spend_erc20_maker_payment_priority_fee() { send_and_spend_erc20_maker_payment_impl(SwapGasFeePolicy::Medium); } -#[cfg(any(feature = "sepolia-maker-swap-v2-tests", feature = "sepolia-taker-swap-v2-tests"))] -/// Wait for all pending transactions for the given address to be confirmed -fn wait_pending_transactions(wallet_address: Address) { - let _guard = SEPOLIA_NONCE_LOCK.lock().unwrap(); - let web3 = SEPOLIA_WEB3.clone(); - - loop { - let latest_nonce = block_on(web3.eth().transaction_count(wallet_address, Some(BlockNumber::Latest))).unwrap(); - let pending_nonce = block_on(web3.eth().transaction_count(wallet_address, Some(BlockNumber::Pending))).unwrap(); - - if latest_nonce == pending_nonce { - log!( - "All pending transactions have been confirmed. Latest nonce: {}", - latest_nonce - ); - break; - } else { - log!( - "Waiting for pending transactions to confirm... Current nonce: {}, Pending nonce: {}", - latest_nonce, - pending_nonce - ); - thread::sleep(Duration::from_secs(1)); - } - } -} - #[test] fn send_and_spend_erc721_maker_payment() { let token_id = 1u32; @@ -1059,12 +740,12 @@ fn test_nonce_several_urls() { // Use one working and one failing URL. let coin = eth_coin_with_random_privkey_using_urls(swap_contract(), &[GETH_RPC_URL, "http://127.0.0.1:0"]); let my_address = block_on(coin.derivation_method().single_addr_or_err()).unwrap(); - let (old_nonce, _) = block_on_f01(coin.clone().get_addr_nonce(my_address)).unwrap(); + let (old_nonce, _) = block_on_f01(coin.clone().get_addr_nonce(my_address.inner())).unwrap(); // Send a payment to increase the nonce. - block_on_f01(coin.send_to_address(my_address, 200000000.into())).unwrap(); + block_on_f01(coin.send_to_address(my_address.inner(), 200000000.into())).unwrap(); - let (new_nonce, _) = block_on_f01(coin.get_addr_nonce(my_address)).unwrap(); + let (new_nonce, _) = block_on_f01(coin.get_addr_nonce(my_address.inner())).unwrap(); assert_eq!(old_nonce + 1, new_nonce); } @@ -1074,7 +755,7 @@ fn test_nonce_lock() { let coin = eth_coin_with_random_privkey(swap_contract()); let my_address = block_on(coin.derivation_method().single_addr_or_err()).unwrap(); - let futures = (0..5).map(|_| coin.send_to_address(my_address, 200000000.into()).compat()); + let futures = (0..5).map(|_| coin.send_to_address(my_address.inner(), 200000000.into()).compat()); let results = block_on(join_all(futures)); // make sure all transactions are successful @@ -1138,10 +819,10 @@ fn test_nonce_erc20_lock() { let my_address = block_on(eth_coin.derivation_method().single_addr_or_err()).unwrap(); let futures = vec![ - eth_coin.send_to_address(my_address, 100.into()).compat(), - eth_token.send_to_address(my_address, 1.into()).compat(), - eth_token.send_to_address(my_address, 2.into()).compat(), - eth_coin.send_to_address(my_address, 200.into()).compat(), + eth_coin.send_to_address(my_address.inner(), 100.into()).compat(), + eth_token.send_to_address(my_address.inner(), 1.into()).compat(), + eth_token.send_to_address(my_address.inner(), 2.into()).compat(), + eth_coin.send_to_address(my_address.inner(), 200.into()).compat(), ]; let results = block_on(join_all(futures)); @@ -1368,8 +1049,8 @@ impl NftActivationV2Args { swap_contract_address: swap_contract(), fallback_swap_contract_address: swap_contract(), swap_v2_contracts: SwapV2Contracts { - maker_swap_v2_contract: maker_swap_v2(), - taker_swap_v2_contract: taker_swap_v2(), + maker_swap_v2_contract: geth_maker_swap_v2(), + taker_swap_v2_contract: geth_taker_swap_v2(), nft_maker_swap_v2_contract: geth_nft_maker_swap_v2(), }, nft_ticker: NFT_ETH.to_string(), @@ -1543,14 +1224,17 @@ impl SwapAddresses { swap_contract_address: swap_contract(), fallback_swap_contract_address: swap_contract(), swap_v2_contracts: SwapV2Contracts { - maker_swap_v2_contract: maker_swap_v2(), - taker_swap_v2_contract: taker_swap_v2(), + maker_swap_v2_contract: geth_maker_swap_v2(), + taker_swap_v2_contract: geth_taker_swap_v2(), nft_maker_swap_v2_contract: geth_nft_maker_swap_v2(), }, } } } +/// ERC20 test token decimals (our test token has 8 decimals) +const ERC20_TOKEN_DECIMALS: u8 = 8; + /// Needed for eth or erc20 v2 activation in Geth tests fn eth_coin_v2_activation_with_random_privkey( ctx: &MmArc, @@ -1568,7 +1252,7 @@ fn eth_coin_v2_activation_with_random_privkey( let platform_request = EthActivationV2Request { nodes: vec![node], rpc_mode: Default::default(), - swap_contract_address: swap_addr.swap_contract_address, + swap_contract_address: Some(swap_addr.swap_contract_address), swap_v2_contracts: Some(swap_addr.swap_v2_contracts), fallback_swap_contract: Some(swap_addr.fallback_swap_contract_address), contract_supports_watchers: false, @@ -1599,19 +1283,17 @@ fn eth_coin_v2_activation_with_random_privkey( platform: ETH.to_string(), token_addr: erc20_contract(), }; - let coin = block_on(coin.set_coin_type(coin_type)); + let coin = block_on(coin.set_coin_type(coin_type, ERC20_TOKEN_DECIMALS)); return (coin, priv_key); } (coin, priv_key) } -#[cfg(feature = "sepolia-taker-swap-v2-tests")] #[test] fn send_and_refund_taker_funding_by_secret_eth() { - let _guard = SEPOLIA_TESTS_LOCK.lock().unwrap(); - - let taker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_TAKER_PRIV, ETH, ð_sepolia_conf(), false); - let maker_coin = get_or_create_sepolia_coin(&MM_CTX, SEPOLIA_MAKER_PRIV, ETH, ð_sepolia_conf(), false); + let swap_addr = SwapAddresses::init(); + let (taker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ETH, ð_dev_conf(), swap_addr, false); + let (maker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX, ETH, ð_dev_conf(), swap_addr, false); let taker_secret = &[0; 32]; let taker_secret_hash = sha256(taker_secret).to_vec(); @@ -1620,8 +1302,6 @@ fn send_and_refund_taker_funding_by_secret_eth() { let funding_time_lock = now_sec() + 3000; let payment_time_lock = now_sec() + 1000; - let taker_address = block_on(taker_coin.my_addr()); - let dex_fee = &DexFee::Standard("0.00001".into()); let trading_amount = BigDecimal::from_str("0.0001").unwrap(); @@ -1638,8 +1318,6 @@ fn send_and_refund_taker_funding_by_secret_eth() { swap_unique_data: &[], }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); - thread::sleep(Duration::from_secs(2)); let funding_tx = block_on(taker_coin.send_taker_funding(payment_args)).unwrap(); log!("Taker sent ETH funding, tx hash: {:02x}", funding_tx.tx_hash()); @@ -1657,32 +1335,27 @@ fn send_and_refund_taker_funding_by_secret_eth() { swap_unique_data: &[], watcher_reward: false, }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let funding_tx_refund = block_on(taker_coin.refund_taker_funding_secret(refund_args)).unwrap(); log!( "Taker refunded ETH funding by secret, tx hash: {:02x}", funding_tx_refund.tx_hash() ); - wait_for_confirmations(&taker_coin, &funding_tx_refund, 100); + wait_for_confirmations(&taker_coin, &funding_tx_refund, 30); } -#[cfg(feature = "sepolia-taker-swap-v2-tests")] #[test] fn send_and_refund_taker_funding_by_secret_erc20() { - let _guard = SEPOLIA_TESTS_LOCK.lock().unwrap(); - - let erc20_conf = &sepolia_erc20_dev_conf(&sepolia_erc20_contract_checksum()); - let taker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_TAKER_PRIV, ERC20, erc20_conf, true); - let maker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_MAKER_PRIV, ERC20, erc20_conf, true); + let swap_addr = SwapAddresses::init(); + let erc20_conf = erc20_dev_conf(&erc20_contract_checksum()); + let (taker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ERC20DEV, &erc20_conf, swap_addr, true); + let (maker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX, ERC20DEV, &erc20_conf, swap_addr, true); let taker_secret = &[0; 32]; let taker_secret_hash = sha256(taker_secret).to_vec(); let maker_secret = [1; 32]; let maker_secret_hash = sha256(&maker_secret).to_vec(); - let taker_address = block_on(taker_coin.my_addr()); - let funding_time_lock = now_sec() + 3000; let payment_time_lock = now_sec() + 1000; @@ -1702,16 +1375,15 @@ fn send_and_refund_taker_funding_by_secret_erc20() { swap_unique_data: &[], }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let funding_tx = block_on(taker_coin.send_taker_funding(payment_args)).unwrap(); log!("Taker sent ERC20 funding, tx hash: {:02x}", funding_tx.tx_hash()); - wait_for_confirmations(&taker_coin, &funding_tx, 200); + wait_for_confirmations(&taker_coin, &funding_tx, 30); let refund_args = RefundFundingSecretArgs { funding_tx: &funding_tx, funding_time_lock, payment_time_lock, - maker_pubkey: &taker_coin.derive_htlc_pubkey_v2(&[]), + maker_pubkey: maker_pub, taker_secret, taker_secret_hash: &taker_secret_hash, maker_secret_hash: &maker_secret_hash, @@ -1721,30 +1393,25 @@ fn send_and_refund_taker_funding_by_secret_erc20() { swap_unique_data: &[], watcher_reward: false, }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let funding_tx_refund = block_on(taker_coin.refund_taker_funding_secret(refund_args)).unwrap(); log!( "Taker refunded ERC20 funding by secret, tx hash: {:02x}", funding_tx_refund.tx_hash() ); - wait_for_confirmations(&taker_coin, &funding_tx_refund, 200); + wait_for_confirmations(&taker_coin, &funding_tx_refund, 30); } -#[cfg(feature = "sepolia-taker-swap-v2-tests")] #[test] fn send_and_refund_taker_funding_exceed_pre_approve_timelock_eth() { - let _guard = SEPOLIA_TESTS_LOCK.lock().unwrap(); - - let taker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_TAKER_PRIV, ETH, ð_sepolia_conf(), false); - let maker_coin = get_or_create_sepolia_coin(&MM_CTX, SEPOLIA_MAKER_PRIV, ETH, ð_sepolia_conf(), false); + let swap_addr = SwapAddresses::init(); + let (taker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ETH, ð_dev_conf(), swap_addr, false); + let (maker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX, ETH, ð_dev_conf(), swap_addr, false); let taker_secret = [0; 32]; let taker_secret_hash = sha256(&taker_secret).to_vec(); let maker_secret = [1; 32]; let maker_secret_hash = sha256(&maker_secret).to_vec(); - let taker_address = block_on(taker_coin.my_addr()); - // if TakerPaymentState is `PaymentSent` then timestamp should exceed payment pre-approve lock time (funding_time_lock) let funding_time_lock = now_sec() - 3000; let payment_time_lock = now_sec() + 1000; @@ -1764,10 +1431,9 @@ fn send_and_refund_taker_funding_exceed_pre_approve_timelock_eth() { trading_amount: trading_amount.clone(), swap_unique_data: &[], }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let funding_tx = block_on(taker_coin.send_taker_funding(payment_args)).unwrap(); log!("Taker sent ETH funding, tx hash: {:02x}", funding_tx.tx_hash()); - wait_for_confirmations(&taker_coin, &funding_tx, 100); + wait_for_confirmations(&taker_coin, &funding_tx, 30); let tx_type_with_secret_hash = SwapTxTypeWithSecretHash::TakerPaymentV2 { maker_secret_hash: &maker_secret_hash, @@ -1785,23 +1451,20 @@ fn send_and_refund_taker_funding_exceed_pre_approve_timelock_eth() { premium_amount: BigDecimal::default(), trading_amount, }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let funding_tx_refund = block_on(taker_coin.refund_taker_funding_timelock(refund_args)).unwrap(); log!( "Taker refunded ETH funding after pre-approval lock time was exceeded, tx hash: {:02x}", funding_tx_refund.tx_hash() ); - wait_for_confirmations(&taker_coin, &funding_tx_refund, 100); + wait_for_confirmations(&taker_coin, &funding_tx_refund, 30); } -#[cfg(feature = "sepolia-taker-swap-v2-tests")] #[test] fn taker_send_approve_and_spend_eth() { - let _guard = SEPOLIA_TESTS_LOCK.lock().unwrap(); - - let taker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_TAKER_PRIV, ETH, ð_sepolia_conf(), false); - let maker_coin = get_or_create_sepolia_coin(&MM_CTX, SEPOLIA_MAKER_PRIV, ETH, ð_sepolia_conf(), false); + let swap_addr = SwapAddresses::init(); + let (taker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ETH, ð_dev_conf(), swap_addr, false); + let (maker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX, ETH, ð_dev_conf(), swap_addr, false); let taker_secret = &[0; 32]; let taker_secret_hash = sha256(taker_secret).to_vec(); @@ -1810,7 +1473,6 @@ fn taker_send_approve_and_spend_eth() { let funding_time_lock = now_sec() + 3000; let payment_time_lock = now_sec() + 600; - let taker_address = block_on(taker_coin.my_addr()); let maker_address = block_on(maker_coin.my_addr()); let dex_fee = &DexFee::Standard("0.00001".into()); @@ -1828,11 +1490,10 @@ fn taker_send_approve_and_spend_eth() { trading_amount: trading_amount.clone(), swap_unique_data: &[], }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let taker_coin_start_block = block_on(taker_coin.current_block().compat()).unwrap(); let funding_tx = block_on(taker_coin.send_taker_funding(payment_args)).unwrap(); log!("Taker sent ETH funding, tx hash: {:02x}", funding_tx.tx_hash()); - wait_for_confirmations(&taker_coin, &funding_tx, 100); + wait_for_confirmations(&taker_coin, &funding_tx, 30); let taker_pub = &taker_coin.derive_htlc_pubkey_v2(&[]); let validate = ValidateTakerFundingArgs { @@ -1862,16 +1523,14 @@ fn taker_send_approve_and_spend_eth() { preimage: funding_tx.clone(), signature: taker_coin.parse_signature(&[0u8; 65]).unwrap(), }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let taker_approve_tx = block_on(taker_coin.sign_and_send_taker_funding_spend(&preimage, &approve_args, &[])).unwrap(); log!( "Taker approved ETH payment, tx hash: {:02x}", taker_approve_tx.tx_hash() ); - wait_for_confirmations(&taker_coin, &taker_approve_tx, 100); + wait_for_confirmations(&taker_coin, &taker_approve_tx, 30); - wait_pending_transactions(Address::from_slice(maker_address.as_bytes())); let check_taker_approved_tx = block_on(maker_coin.search_for_taker_funding_spend(&funding_tx, 0u64, &[])) .unwrap() .unwrap(); @@ -1895,11 +1554,10 @@ fn taker_send_approve_and_spend_eth() { premium_amount: Default::default(), trading_amount, }; - wait_pending_transactions(Address::from_slice(maker_address.as_bytes())); let spend_tx = block_on(maker_coin.sign_and_broadcast_taker_payment_spend(None, &spend_args, maker_secret, &[])).unwrap(); log!("Maker spent ETH payment, tx hash: {:02x}", spend_tx.tx_hash()); - wait_for_confirmations(&maker_coin, &spend_tx, 100); + wait_for_confirmations(&maker_coin, &spend_tx, 30); let found_spend_tx = block_on(taker_coin.find_taker_payment_spend_tx(&taker_approve_tx, taker_coin_start_block, payment_time_lock)) .unwrap(); @@ -1907,14 +1565,12 @@ fn taker_send_approve_and_spend_eth() { assert_eq!(maker_secret, &extracted_maker_secret); } -#[cfg(feature = "sepolia-taker-swap-v2-tests")] #[test] fn taker_send_approve_and_spend_erc20() { - let _guard = SEPOLIA_TESTS_LOCK.lock().unwrap(); - - let erc20_conf = &sepolia_erc20_dev_conf(&sepolia_erc20_contract_checksum()); - let taker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_TAKER_PRIV, ERC20, erc20_conf, true); - let maker_coin = get_or_create_sepolia_coin(&MM_CTX, SEPOLIA_MAKER_PRIV, ERC20, erc20_conf, true); + let swap_addr = SwapAddresses::init(); + let erc20_conf = erc20_dev_conf(&erc20_contract_checksum()); + let (taker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ERC20DEV, &erc20_conf, swap_addr, true); + let (maker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX, ERC20DEV, &erc20_conf, swap_addr, true); let taker_secret = [0; 32]; let taker_secret_hash = sha256(&taker_secret).to_vec(); @@ -1923,10 +1579,9 @@ fn taker_send_approve_and_spend_erc20() { let funding_time_lock = now_sec() + 3000; let payment_time_lock = now_sec() + 600; - let taker_address = block_on(taker_coin.my_addr()); let maker_address = block_on(maker_coin.my_addr()); - let dex_fee = &DexFee::NoFee; + let dex_fee = &DexFee::Standard("0.00001".into()); let trading_amount = BigDecimal::from_str("0.0001").unwrap(); let maker_pub = &maker_coin.derive_htlc_pubkey_v2(&[]); @@ -1941,11 +1596,10 @@ fn taker_send_approve_and_spend_erc20() { trading_amount: trading_amount.clone(), swap_unique_data: &[], }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let taker_coin_start_block = block_on(taker_coin.current_block().compat()).unwrap(); let funding_tx = block_on(taker_coin.send_taker_funding(payment_args)).unwrap(); log!("Taker sent ERC20 funding, tx hash: {:02x}", funding_tx.tx_hash()); - wait_for_confirmations(&taker_coin, &funding_tx, 100); + wait_for_confirmations(&taker_coin, &funding_tx, 30); let taker_pub = &taker_coin.derive_htlc_pubkey_v2(&[]); let validate = ValidateTakerFundingArgs { @@ -1975,16 +1629,14 @@ fn taker_send_approve_and_spend_erc20() { preimage: funding_tx.clone(), signature: taker_coin.parse_signature(&[0u8; 65]).unwrap(), }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let taker_approve_tx = block_on(taker_coin.sign_and_send_taker_funding_spend(&preimage, &approve_args, &[])).unwrap(); log!( "Taker approved ERC20 payment, tx hash: {:02x}", taker_approve_tx.tx_hash() ); - wait_for_confirmations(&taker_coin, &taker_approve_tx, 100); + wait_for_confirmations(&taker_coin, &taker_approve_tx, 30); - wait_pending_transactions(Address::from_slice(maker_address.as_bytes())); let check_taker_approved_tx = block_on(maker_coin.search_for_taker_funding_spend(&funding_tx, 0u64, &[])) .unwrap() .unwrap(); @@ -2008,7 +1660,6 @@ fn taker_send_approve_and_spend_erc20() { premium_amount: Default::default(), trading_amount, }; - wait_pending_transactions(Address::from_slice(maker_address.as_bytes())); let spend_tx = block_on(maker_coin.sign_and_broadcast_taker_payment_spend(None, &spend_args, &maker_secret, &[])).unwrap(); log!("Maker spent ERC20 payment, tx hash: {:02x}", spend_tx.tx_hash()); @@ -2019,13 +1670,11 @@ fn taker_send_approve_and_spend_erc20() { assert_eq!(maker_secret, extracted_maker_secret); } -#[cfg(feature = "sepolia-taker-swap-v2-tests")] #[test] fn send_and_refund_taker_funding_exceed_payment_timelock_eth() { - let _guard = SEPOLIA_TESTS_LOCK.lock().unwrap(); - - let taker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_TAKER_PRIV, ETH, ð_sepolia_conf(), false); - let maker_coin = get_or_create_sepolia_coin(&MM_CTX, SEPOLIA_MAKER_PRIV, ETH, ð_sepolia_conf(), false); + let swap_addr = SwapAddresses::init(); + let (taker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ETH, ð_dev_conf(), swap_addr, false); + let (maker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX, ETH, ð_dev_conf(), swap_addr, false); let taker_secret = [0; 32]; let taker_secret_hash = sha256(&taker_secret).to_vec(); @@ -2034,8 +1683,6 @@ fn send_and_refund_taker_funding_exceed_payment_timelock_eth() { let funding_time_lock = now_sec() + 3000; let payment_time_lock = now_sec() - 1000; - let taker_address = block_on(taker_coin.my_addr()); - let dex_fee = &DexFee::Standard("0.00001".into()); let trading_amount = BigDecimal::from_str("0.0001").unwrap(); @@ -2051,10 +1698,9 @@ fn send_and_refund_taker_funding_exceed_payment_timelock_eth() { trading_amount: trading_amount.clone(), swap_unique_data: &[], }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let funding_tx = block_on(taker_coin.send_taker_funding(payment_args)).unwrap(); log!("Taker sent ETH funding, tx hash: {:02x}", funding_tx.tx_hash()); - wait_for_confirmations(&taker_coin, &funding_tx, 100); + wait_for_confirmations(&taker_coin, &funding_tx, 30); let taker_pub = &taker_coin.derive_htlc_pubkey_v2(&[]); let approve_args = GenTakerFundingSpendArgs { @@ -2070,14 +1716,13 @@ fn send_and_refund_taker_funding_exceed_payment_timelock_eth() { preimage: funding_tx.clone(), signature: taker_coin.parse_signature(&[0u8; 65]).unwrap(), }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let taker_approve_tx = block_on(taker_coin.sign_and_send_taker_funding_spend(&preimage, &approve_args, &[])).unwrap(); log!( "Taker approved ETH payment, tx hash: {:02x}", taker_approve_tx.tx_hash() ); - wait_for_confirmations(&taker_coin, &taker_approve_tx, 100); + wait_for_confirmations(&taker_coin, &taker_approve_tx, 30); let tx_type_with_secret_hash = SwapTxTypeWithSecretHash::TakerPaymentV2 { maker_secret_hash: &maker_secret_hash, @@ -2094,23 +1739,20 @@ fn send_and_refund_taker_funding_exceed_payment_timelock_eth() { premium_amount: BigDecimal::default(), trading_amount, }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let funding_tx_refund = block_on(taker_coin.refund_taker_funding_timelock(refund_args)).unwrap(); log!( "Taker refunded ETH funding after payment lock time was exceeded, tx hash: {:02x}", funding_tx_refund.tx_hash() ); - wait_for_confirmations(&taker_coin, &funding_tx_refund, 100); + wait_for_confirmations(&taker_coin, &funding_tx_refund, 30); } -#[cfg(feature = "sepolia-taker-swap-v2-tests")] #[test] fn send_and_refund_taker_funding_exceed_payment_timelock_erc20() { - let _guard = SEPOLIA_TESTS_LOCK.lock().unwrap(); - - let erc20_conf = &sepolia_erc20_dev_conf(&sepolia_erc20_contract_checksum()); - let taker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_TAKER_PRIV, ERC20, erc20_conf, true); - let maker_coin = get_or_create_sepolia_coin(&MM_CTX, SEPOLIA_MAKER_PRIV, ERC20, erc20_conf, true); + let swap_addr = SwapAddresses::init(); + let erc20_conf = erc20_dev_conf(&erc20_contract_checksum()); + let (taker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ERC20DEV, &erc20_conf, swap_addr, true); + let (maker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX, ERC20DEV, &erc20_conf, swap_addr, true); let taker_secret = [0; 32]; let taker_secret_hash = sha256(&taker_secret).to_vec(); @@ -2119,8 +1761,6 @@ fn send_and_refund_taker_funding_exceed_payment_timelock_erc20() { let funding_time_lock = now_sec() + 29; let payment_time_lock = now_sec() + 15; - let taker_address = block_on(taker_coin.my_addr()); - let dex_fee = &DexFee::Standard("0.00001".into()); let trading_amount = BigDecimal::from_str("0.0001").unwrap(); @@ -2136,10 +1776,9 @@ fn send_and_refund_taker_funding_exceed_payment_timelock_erc20() { trading_amount: trading_amount.clone(), swap_unique_data: &[], }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let funding_tx = block_on(taker_coin.send_taker_funding(payment_args)).unwrap(); log!("Taker sent ERC20 funding, tx hash: {:02x}", funding_tx.tx_hash()); - wait_for_confirmations(&taker_coin, &funding_tx, 100); + wait_for_confirmations(&taker_coin, &funding_tx, 30); thread::sleep(Duration::from_secs(16)); let taker_pub = &taker_coin.derive_htlc_pubkey_v2(&[]); @@ -2156,14 +1795,13 @@ fn send_and_refund_taker_funding_exceed_payment_timelock_erc20() { preimage: funding_tx.clone(), signature: taker_coin.parse_signature(&[0u8; 65]).unwrap(), }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let taker_approve_tx = block_on(taker_coin.sign_and_send_taker_funding_spend(&preimage, &approve_args, &[])).unwrap(); log!( "Taker approved ERC20 payment, tx hash: {:02x}", taker_approve_tx.tx_hash() ); - wait_for_confirmations(&taker_coin, &taker_approve_tx, 100); + wait_for_confirmations(&taker_coin, &taker_approve_tx, 30); let tx_type_with_secret_hash = SwapTxTypeWithSecretHash::TakerPaymentV2 { maker_secret_hash: &maker_secret_hash, @@ -2180,31 +1818,26 @@ fn send_and_refund_taker_funding_exceed_payment_timelock_erc20() { premium_amount: BigDecimal::default(), trading_amount, }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let funding_tx_refund = block_on(taker_coin.refund_taker_funding_timelock(refund_args)).unwrap(); log!( "Taker refunded ERC20 funding after payment lock time was exceeded, tx hash: {:02x}", funding_tx_refund.tx_hash() ); - wait_for_confirmations(&taker_coin, &funding_tx_refund, 100); + wait_for_confirmations(&taker_coin, &funding_tx_refund, 30); } -#[cfg(feature = "sepolia-taker-swap-v2-tests")] #[test] fn send_and_refund_taker_funding_exceed_pre_approve_timelock_erc20() { - let _guard = SEPOLIA_TESTS_LOCK.lock().unwrap(); - - let erc20_conf = &sepolia_erc20_dev_conf(&sepolia_erc20_contract_checksum()); - let taker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_TAKER_PRIV, ERC20, erc20_conf, true); - let maker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_MAKER_PRIV, ERC20, erc20_conf, true); + let swap_addr = SwapAddresses::init(); + let erc20_conf = erc20_dev_conf(&erc20_contract_checksum()); + let (taker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ERC20DEV, &erc20_conf, swap_addr, true); + let (maker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ERC20DEV, &erc20_conf, swap_addr, true); let taker_secret = [0; 32]; let taker_secret_hash = sha256(&taker_secret).to_vec(); let maker_secret = [1; 32]; let maker_secret_hash = sha256(&maker_secret).to_vec(); - let taker_address = block_on(taker_coin.my_addr()); - // if TakerPaymentState is `PaymentSent` then timestamp should exceed payment pre-approve lock time (funding_time_lock) let funding_time_lock = now_sec() + 29; let payment_time_lock = now_sec() + 1000; @@ -2224,10 +1857,9 @@ fn send_and_refund_taker_funding_exceed_pre_approve_timelock_erc20() { trading_amount: trading_amount.clone(), swap_unique_data: &[], }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let funding_tx = block_on(taker_coin.send_taker_funding(payment_args)).unwrap(); log!("Taker sent ERC20 funding, tx hash: {:02x}", funding_tx.tx_hash()); - wait_for_confirmations(&taker_coin, &funding_tx, 150); + wait_for_confirmations(&taker_coin, &funding_tx, 30); thread::sleep(Duration::from_secs(29)); let tx_type_with_secret_hash = SwapTxTypeWithSecretHash::TakerPaymentV2 { @@ -2246,22 +1878,19 @@ fn send_and_refund_taker_funding_exceed_pre_approve_timelock_erc20() { premium_amount: BigDecimal::default(), trading_amount, }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let funding_tx_refund = block_on(taker_coin.refund_taker_funding_timelock(refund_args)).unwrap(); log!( "Taker refunded ERC20 funding after pre-approval lock time was exceeded, tx hash: {:02x}", funding_tx_refund.tx_hash() ); - wait_for_confirmations(&taker_coin, &funding_tx_refund, 150); + wait_for_confirmations(&taker_coin, &funding_tx_refund, 30); } -#[cfg(feature = "sepolia-maker-swap-v2-tests")] #[test] fn send_maker_payment_and_refund_timelock_eth() { - let _guard = SEPOLIA_TESTS_LOCK.lock().unwrap(); - - let taker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_TAKER_PRIV, ETH, ð_sepolia_conf(), false); - let maker_coin = get_or_create_sepolia_coin(&MM_CTX, SEPOLIA_MAKER_PRIV, ETH, ð_sepolia_conf(), false); + let swap_addr = SwapAddresses::init(); + let (taker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ETH, ð_dev_conf(), swap_addr, false); + let (maker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX, ETH, ð_dev_conf(), swap_addr, false); let taker_secret = [0; 32]; let taker_secret_hash = sha256(&taker_secret).to_vec(); @@ -2269,7 +1898,6 @@ fn send_maker_payment_and_refund_timelock_eth() { let maker_secret_hash = sha256(&maker_secret).to_vec(); let payment_time_lock = now_sec() - 1000; - let maker_address = block_on(maker_coin.my_addr()); let taker_pub = &taker_coin.derive_htlc_pubkey_v2(&[]); let trading_amount = BigDecimal::from_str("0.0001").unwrap(); @@ -2282,10 +1910,9 @@ fn send_maker_payment_and_refund_timelock_eth() { taker_pub, swap_unique_data: &[], }; - wait_pending_transactions(Address::from_slice(maker_address.as_bytes())); let payment_tx = block_on(maker_coin.send_maker_payment_v2(payment_args)).unwrap(); log!("Maker sent ETH payment, tx hash: {:02x}", payment_tx.tx_hash()); - wait_for_confirmations(&maker_coin, &payment_tx, 100); + wait_for_confirmations(&maker_coin, &payment_tx, 30); let tx_type_with_secret_hash = SwapTxTypeWithSecretHash::MakerPaymentV2 { maker_secret_hash: &maker_secret_hash, @@ -2300,23 +1927,20 @@ fn send_maker_payment_and_refund_timelock_eth() { watcher_reward: false, amount: trading_amount, }; - wait_pending_transactions(Address::from_slice(maker_address.as_bytes())); let payment_tx_refund = block_on(maker_coin.refund_maker_payment_v2_timelock(refund_args)).unwrap(); log!( "Maker refunded ETH payment after timelock, tx hash: {:02x}", payment_tx_refund.tx_hash() ); - wait_for_confirmations(&maker_coin, &payment_tx_refund, 100); + wait_for_confirmations(&maker_coin, &payment_tx_refund, 30); } -#[cfg(feature = "sepolia-maker-swap-v2-tests")] #[test] fn send_maker_payment_and_refund_timelock_erc20() { - let _guard = SEPOLIA_TESTS_LOCK.lock().unwrap(); - - let erc20_conf = &sepolia_erc20_dev_conf(&sepolia_erc20_contract_checksum()); - let taker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_TAKER_PRIV, ERC20, erc20_conf, true); - let maker_coin = get_or_create_sepolia_coin(&MM_CTX, SEPOLIA_MAKER_PRIV, ERC20, erc20_conf, true); + let swap_addr = SwapAddresses::init(); + let erc20_conf = erc20_dev_conf(&erc20_contract_checksum()); + let (taker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ERC20DEV, &erc20_conf, swap_addr, true); + let (maker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX, ERC20DEV, &erc20_conf, swap_addr, true); let taker_secret = [0; 32]; let taker_secret_hash = sha256(&taker_secret).to_vec(); @@ -2324,11 +1948,17 @@ fn send_maker_payment_and_refund_timelock_erc20() { let maker_secret_hash = sha256(&maker_secret).to_vec(); let payment_time_lock = now_sec() - 1000; - let maker_address = block_on(maker_coin.my_addr()); let taker_pub = &taker_coin.derive_htlc_pubkey_v2(&[]); let trading_amount = BigDecimal::from_str("0.0001").unwrap(); + // Pre-approve the ERC20 token for maker swap v2 contract since the payment time_lock + // is in the past (for refund testing) and handle_allowance would timeout immediately. + let approve_tx = + block_on_f01(maker_coin.approve(swap_addr.swap_v2_contracts.maker_swap_v2_contract, U256::max_value())) + .unwrap(); + wait_for_confirmations(&maker_coin, &approve_tx, 30); + let payment_args = SendMakerPaymentArgs { time_lock: payment_time_lock, taker_secret_hash: &taker_secret_hash, @@ -2337,10 +1967,9 @@ fn send_maker_payment_and_refund_timelock_erc20() { taker_pub, swap_unique_data: &[], }; - wait_pending_transactions(Address::from_slice(maker_address.as_bytes())); let payment_tx = block_on(maker_coin.send_maker_payment_v2(payment_args)).unwrap(); log!("Maker sent ERC20 payment, tx hash: {:02x}", payment_tx.tx_hash()); - wait_for_confirmations(&maker_coin, &payment_tx, 100); + wait_for_confirmations(&maker_coin, &payment_tx, 30); let tx_type_with_secret_hash = SwapTxTypeWithSecretHash::MakerPaymentV2 { maker_secret_hash: &maker_secret_hash, @@ -2355,22 +1984,19 @@ fn send_maker_payment_and_refund_timelock_erc20() { watcher_reward: false, amount: trading_amount, }; - wait_pending_transactions(Address::from_slice(maker_address.as_bytes())); let payment_tx_refund = block_on(maker_coin.refund_maker_payment_v2_timelock(refund_args)).unwrap(); log!( "Maker refunded ERC20 payment after timelock, tx hash: {:02x}", payment_tx_refund.tx_hash() ); - wait_for_confirmations(&maker_coin, &payment_tx_refund, 100); + wait_for_confirmations(&maker_coin, &payment_tx_refund, 30); } -#[cfg(feature = "sepolia-maker-swap-v2-tests")] #[test] fn send_maker_payment_and_refund_secret_eth() { - let _guard = SEPOLIA_TESTS_LOCK.lock().unwrap(); - - let taker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_TAKER_PRIV, ETH, ð_sepolia_conf(), false); - let maker_coin = get_or_create_sepolia_coin(&MM_CTX, SEPOLIA_MAKER_PRIV, ETH, ð_sepolia_conf(), false); + let swap_addr = SwapAddresses::init(); + let (taker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ETH, ð_dev_conf(), swap_addr, false); + let (maker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX, ETH, ð_dev_conf(), swap_addr, false); let taker_secret = &[0; 32]; let taker_secret_hash = sha256(taker_secret).to_vec(); @@ -2378,7 +2004,6 @@ fn send_maker_payment_and_refund_secret_eth() { let maker_secret_hash = sha256(maker_secret).to_vec(); let payment_time_lock = now_sec() + 1000; - let maker_address = block_on(maker_coin.my_addr()); let taker_pub = &taker_coin.derive_htlc_pubkey_v2(&[]); let trading_amount = BigDecimal::from_str("0.0001").unwrap(); @@ -2391,10 +2016,9 @@ fn send_maker_payment_and_refund_secret_eth() { taker_pub, swap_unique_data: &[], }; - wait_pending_transactions(Address::from_slice(maker_address.as_bytes())); let payment_tx = block_on(maker_coin.send_maker_payment_v2(payment_args)).unwrap(); log!("Maker sent ETH payment, tx hash: {:02x}", payment_tx.tx_hash()); - wait_for_confirmations(&maker_coin, &payment_tx, 100); + wait_for_confirmations(&maker_coin, &payment_tx, 30); let refund_args = RefundMakerPaymentSecretArgs { maker_payment_tx: &payment_tx, @@ -2406,23 +2030,20 @@ fn send_maker_payment_and_refund_secret_eth() { swap_unique_data: &[], amount: trading_amount, }; - wait_pending_transactions(Address::from_slice(maker_address.as_bytes())); let payment_tx_refund = block_on(maker_coin.refund_maker_payment_v2_secret(refund_args)).unwrap(); log!( "Maker refunded ETH payment using taker secret, tx hash: {:02x}", payment_tx_refund.tx_hash() ); - wait_for_confirmations(&maker_coin, &payment_tx_refund, 100); + wait_for_confirmations(&maker_coin, &payment_tx_refund, 30); } -#[cfg(feature = "sepolia-maker-swap-v2-tests")] #[test] fn send_maker_payment_and_refund_secret_erc20() { - let _guard = SEPOLIA_TESTS_LOCK.lock().unwrap(); - - let erc20_conf = &sepolia_erc20_dev_conf(&sepolia_erc20_contract_checksum()); - let taker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_TAKER_PRIV, ERC20, erc20_conf, true); - let maker_coin = get_or_create_sepolia_coin(&MM_CTX, SEPOLIA_MAKER_PRIV, ERC20, erc20_conf, true); + let swap_addr = SwapAddresses::init(); + let erc20_conf = erc20_dev_conf(&erc20_contract_checksum()); + let (taker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ERC20DEV, &erc20_conf, swap_addr, true); + let (maker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX, ERC20DEV, &erc20_conf, swap_addr, true); let taker_secret = &[0; 32]; let taker_secret_hash = sha256(taker_secret).to_vec(); @@ -2430,7 +2051,6 @@ fn send_maker_payment_and_refund_secret_erc20() { let maker_secret_hash = sha256(maker_secret).to_vec(); let payment_time_lock = now_sec() + 1000; - let maker_address = block_on(maker_coin.my_addr()); let taker_pub = &taker_coin.derive_htlc_pubkey_v2(&[]); let trading_amount = BigDecimal::from_str("0.0001").unwrap(); @@ -2443,10 +2063,9 @@ fn send_maker_payment_and_refund_secret_erc20() { taker_pub, swap_unique_data: &[], }; - wait_pending_transactions(Address::from_slice(maker_address.as_bytes())); let payment_tx = block_on(maker_coin.send_maker_payment_v2(payment_args)).unwrap(); log!("Maker sent ERC20 payment, tx hash: {:02x}", payment_tx.tx_hash()); - wait_for_confirmations(&maker_coin, &payment_tx, 100); + wait_for_confirmations(&maker_coin, &payment_tx, 30); let refund_args = RefundMakerPaymentSecretArgs { maker_payment_tx: &payment_tx, @@ -2458,22 +2077,19 @@ fn send_maker_payment_and_refund_secret_erc20() { swap_unique_data: &[], amount: trading_amount, }; - wait_pending_transactions(Address::from_slice(maker_address.as_bytes())); let payment_tx_refund = block_on(maker_coin.refund_maker_payment_v2_secret(refund_args)).unwrap(); log!( "Maker refunded ERC20 payment using taker secret, tx hash: {:02x}", payment_tx_refund.tx_hash() ); - wait_for_confirmations(&maker_coin, &payment_tx_refund, 100); + wait_for_confirmations(&maker_coin, &payment_tx_refund, 30); } -#[cfg(feature = "sepolia-maker-swap-v2-tests")] #[test] fn send_and_spend_maker_payment_eth() { - let _guard = SEPOLIA_TESTS_LOCK.lock().unwrap(); - - let taker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_TAKER_PRIV, ETH, ð_sepolia_conf(), false); - let maker_coin = get_or_create_sepolia_coin(&MM_CTX, SEPOLIA_MAKER_PRIV, ETH, ð_sepolia_conf(), false); + let swap_addr = SwapAddresses::init(); + let (taker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ETH, ð_dev_conf(), swap_addr, false); + let (maker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX, ETH, ð_dev_conf(), swap_addr, false); let taker_secret = [0; 32]; let taker_secret_hash = sha256(&taker_secret).to_vec(); @@ -2481,8 +2097,6 @@ fn send_and_spend_maker_payment_eth() { let maker_secret_hash = sha256(&maker_secret).to_vec(); let payment_time_lock = now_sec() + 1000; - let maker_address = block_on(maker_coin.my_addr()); - let taker_address = block_on(taker_coin.my_addr()); let maker_pub = &maker_coin.derive_htlc_pubkey_v2(&[]); let taker_pub = &taker_coin.derive_htlc_pubkey_v2(&[]); @@ -2496,10 +2110,9 @@ fn send_and_spend_maker_payment_eth() { taker_pub, swap_unique_data: &[], }; - wait_pending_transactions(Address::from_slice(maker_address.as_bytes())); let payment_tx = block_on(maker_coin.send_maker_payment_v2(payment_args)).unwrap(); log!("Maker sent ETH payment, tx hash: {:02x}", payment_tx.tx_hash()); - wait_for_confirmations(&maker_coin, &payment_tx, 100); + wait_for_confirmations(&maker_coin, &payment_tx, 30); let validation_args = ValidateMakerPaymentArgs { maker_payment_tx: &payment_tx, @@ -2523,20 +2136,17 @@ fn send_and_spend_maker_payment_eth() { swap_unique_data: &[], amount: trading_amount, }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let spend_tx = block_on(taker_coin.spend_maker_payment_v2(spend_args)).unwrap(); log!("Taker spent maker ETH payment, tx hash: {:02x}", spend_tx.tx_hash()); - wait_for_confirmations(&taker_coin, &spend_tx, 100); + wait_for_confirmations(&taker_coin, &spend_tx, 30); } -#[cfg(feature = "sepolia-maker-swap-v2-tests")] #[test] fn send_and_spend_maker_payment_erc20() { - let _guard = SEPOLIA_TESTS_LOCK.lock().unwrap(); - - let erc20_conf = &sepolia_erc20_dev_conf(&sepolia_erc20_contract_checksum()); - let taker_coin = get_or_create_sepolia_coin(&MM_CTX1, SEPOLIA_TAKER_PRIV, ERC20, erc20_conf, true); - let maker_coin = get_or_create_sepolia_coin(&MM_CTX, SEPOLIA_MAKER_PRIV, ERC20, erc20_conf, true); + let swap_addr = SwapAddresses::init(); + let erc20_conf = erc20_dev_conf(&erc20_contract_checksum()); + let (taker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX1, ERC20DEV, &erc20_conf, swap_addr, true); + let (maker_coin, _) = eth_coin_v2_activation_with_random_privkey(&MM_CTX, ERC20DEV, &erc20_conf, swap_addr, true); let taker_secret = [0; 32]; let taker_secret_hash = sha256(&taker_secret).to_vec(); @@ -2544,8 +2154,6 @@ fn send_and_spend_maker_payment_erc20() { let maker_secret_hash = sha256(&maker_secret).to_vec(); let payment_time_lock = now_sec() + 1000; - let maker_address = block_on(maker_coin.my_addr()); - let taker_address = block_on(taker_coin.my_addr()); let maker_pub = &maker_coin.derive_htlc_pubkey_v2(&[]); let taker_pub = &taker_coin.derive_htlc_pubkey_v2(&[]); @@ -2559,10 +2167,9 @@ fn send_and_spend_maker_payment_erc20() { taker_pub, swap_unique_data: &[], }; - wait_pending_transactions(Address::from_slice(maker_address.as_bytes())); let payment_tx = block_on(maker_coin.send_maker_payment_v2(payment_args)).unwrap(); log!("Maker sent ERC20 payment, tx hash: {:02x}", payment_tx.tx_hash()); - wait_for_confirmations(&maker_coin, &payment_tx, 100); + wait_for_confirmations(&maker_coin, &payment_tx, 30); let validation_args = ValidateMakerPaymentArgs { maker_payment_tx: &payment_tx, @@ -2586,10 +2193,9 @@ fn send_and_spend_maker_payment_erc20() { swap_unique_data: &[], amount: trading_amount, }; - wait_pending_transactions(Address::from_slice(taker_address.as_bytes())); let spend_tx = block_on(taker_coin.spend_maker_payment_v2(spend_args)).unwrap(); log!("Taker spent maker ERC20 payment, tx hash: {:02x}", spend_tx.tx_hash()); - wait_for_confirmations(&taker_coin, &spend_tx, 100); + wait_for_confirmations(&taker_coin, &spend_tx, 30); } #[test] @@ -2597,7 +2203,7 @@ fn test_eth_erc20_hd() { const PASSPHRASE: &str = "tank abandon bind salon remove wisdom net size aspect direct source fossil"; let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); - let swap_contract = format!("0x{}", hex::encode(swap_contract())); + let swap_contract = swap_contract_checksum(); // Withdraw from HD account 0, change address 0, index 0 let path_to_address = HDAccountAddressId::default(); @@ -2610,7 +2216,7 @@ fn test_eth_erc20_hd() { &mm_hd, "ETH", &["ERC20DEV"], - &swap_contract, + Some(&swap_contract), &[GETH_RPC_URL], 60, Some(path_to_address), @@ -2649,7 +2255,7 @@ fn test_eth_erc20_hd() { &mm_hd, "ETH", &["ERC20DEV"], - &swap_contract, + Some(&swap_contract), &[GETH_RPC_URL], 60, Some(path_to_address), @@ -2680,7 +2286,7 @@ fn test_eth_erc20_hd() { "0x4249E165a68E4FF9C41B1C3C3b4245c30ecB43CC" ); // Make sure that the address is also added to tokens - let account_balance = block_on(account_balance(&mm_hd, "ERC20DEV", 0, Bip44Chain::External)); + let account_balance = block_on(account_balance(&mm_hd, "ERC20DEV", 0, Bip44Chain::External, None)); assert_eq!( account_balance.addresses[2].address, "0x4249E165a68E4FF9C41B1C3C3b4245c30ecB43CC" @@ -2703,7 +2309,7 @@ fn test_eth_erc20_hd() { &mm_hd, "ETH", &["ERC20DEV"], - &swap_contract, + Some(&swap_contract), &[GETH_RPC_URL], 60, Some(path_to_address), @@ -2733,7 +2339,7 @@ fn test_enable_custom_erc20() { const PASSPHRASE: &str = "tank abandon bind salon remove wisdom net size aspect direct source fossil"; let coins = json!([eth_dev_conf()]); - let swap_contract = format!("0x{}", hex::encode(swap_contract())); + let swap_contract = swap_contract_checksum(); let path_to_address = HDAccountAddressId::default(); let conf = Mm2TestConf::seednode_with_hd_account(PASSPHRASE, &coins); @@ -2746,7 +2352,7 @@ fn test_enable_custom_erc20() { &mm_hd, "ETH", &[], - &swap_contract, + Some(&swap_contract), &[GETH_RPC_URL], 60, Some(path_to_address.clone()), @@ -2817,7 +2423,7 @@ fn test_enable_custom_erc20_with_duplicate_contract_in_config() { let erc20_dev_conf = erc20_dev_conf(&erc20_contract_checksum()); let coins = json!([eth_dev_conf(), erc20_dev_conf]); - let swap_contract = format!("0x{}", hex::encode(swap_contract())); + let swap_contract = swap_contract_checksum(); let path_to_address = HDAccountAddressId::default(); let conf = Mm2TestConf::seednode_with_hd_account(PASSPHRASE, &coins); @@ -2830,7 +2436,7 @@ fn test_enable_custom_erc20_with_duplicate_contract_in_config() { &mm_hd, "ETH", &[], - &swap_contract, + Some(&swap_contract), &[GETH_RPC_URL], 60, Some(path_to_address.clone()), @@ -3106,7 +2712,7 @@ fn test_v2_eth_eth_kickstart_impl(base: &str, rel: &str, maker_price: f64, taker rel, &alice_rel_balance_0.balance, &alice_rel_balance_1.balance, - volume * (1.0 + 1.0 / 777.0), + volume * 1.02, // 2% DEX fee Some(taker_price), "sell", ); @@ -3155,3 +2761,206 @@ fn verify_locked_amount(mm: &MarketMakerIt, role: &str, coin: &str) { log!("{} {} locked amount: {:?}", role, coin, locked.locked_amount); assert_eq!(locked.coin, coin); } + +// ================================ +// USDT (Non-Standard ERC20) Tests +// ================================ +// These tests verify that SafeERC20 in the V1 EtomicSwap contract +// correctly handles USDT's non-standard transfer/transferFrom functions +// which don't return a boolean value. + +fn send_and_spend_usdt_maker_payment_impl(swap_txfee_policy: SwapGasFeePolicy) { + let maker_usdt_coin = usdt_coin_with_random_privkey(swap_contract()); + let taker_usdt_coin = usdt_coin_with_random_privkey(swap_contract()); + + assert!(block_on(maker_usdt_coin.set_swap_gas_fee_policy(swap_txfee_policy.clone())).is_ok()); + assert!(block_on(taker_usdt_coin.set_swap_gas_fee_policy(swap_txfee_policy)).is_ok()); + + let time_lock = now_sec() + 1000; + let maker_pubkey = maker_usdt_coin.derive_htlc_pubkey(&[]); + let taker_pubkey = taker_usdt_coin.derive_htlc_pubkey(&[]); + let secret = &[2; 32]; + let secret_hash_owned = dhash160(secret); + let secret_hash = secret_hash_owned.as_slice(); + + let send_payment_args = SendPaymentArgs { + time_lock_duration: 1000, + time_lock, + other_pubkey: &taker_pubkey, + secret_hash, + amount: BigDecimal::from_str("10").unwrap(), + swap_contract_address: &Some(swap_contract().as_bytes().into()), + swap_unique_data: &[], + payment_instructions: &None, + watcher_reward: None, + wait_for_confirmation_until: now_sec() + 60, + }; + let usdt_maker_payment = block_on(maker_usdt_coin.send_maker_payment(send_payment_args)).unwrap(); + log!( + "USDT maker payment tx hash {:02x}", + usdt_maker_payment.tx_hash_as_bytes() + ); + + let confirm_input = ConfirmPaymentInput { + payment_tx: usdt_maker_payment.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: now_sec() + 60, + check_every: 1, + }; + block_on_f01(taker_usdt_coin.wait_for_confirmations(confirm_input)).unwrap(); + + let spend_args = SpendPaymentArgs { + other_payment_tx: &usdt_maker_payment.tx_hex(), + time_lock, + other_pubkey: &maker_pubkey, + secret, + secret_hash, + swap_contract_address: &Some(swap_contract().as_bytes().into()), + swap_unique_data: &[], + watcher_reward: false, + }; + let payment_spend = block_on(taker_usdt_coin.send_taker_spends_maker_payment(spend_args)).unwrap(); + log!("USDT payment spend tx hash {:02x}", payment_spend.tx_hash_as_bytes()); + + let confirm_input = ConfirmPaymentInput { + payment_tx: payment_spend.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: now_sec() + 60, + check_every: 1, + }; + block_on_f01(taker_usdt_coin.wait_for_confirmations(confirm_input)).unwrap(); + + let search_input = SearchForSwapTxSpendInput { + time_lock, + other_pub: &taker_pubkey, + secret_hash, + tx: &usdt_maker_payment.tx_hex(), + search_from_block: 0, + swap_contract_address: &Some(swap_contract().as_bytes().into()), + swap_unique_data: &[], + }; + let search_tx = block_on(maker_usdt_coin.search_for_swap_tx_spend_my(search_input)) + .unwrap() + .unwrap(); + + let expected = FoundSwapTxSpend::Spent(payment_spend); + assert_eq!(expected, search_tx); +} + +#[test] +fn send_and_spend_usdt_maker_payment_legacy_gas_policy() { + send_and_spend_usdt_maker_payment_impl(SwapGasFeePolicy::Legacy); +} + +#[test] +fn send_and_spend_usdt_maker_payment_priority_fee() { + send_and_spend_usdt_maker_payment_impl(SwapGasFeePolicy::Medium); +} + +fn send_and_refund_usdt_maker_payment_impl(swap_txfee_policy: SwapGasFeePolicy) { + let usdt_coin = usdt_coin_with_random_privkey(swap_contract()); + assert!(block_on(usdt_coin.set_swap_gas_fee_policy(swap_txfee_policy)).is_ok()); + + // Use a past time_lock to allow immediate refund + let time_lock = now_sec() - 100; + let other_pubkey = &[ + 0x02, 0xc6, 0x6e, 0x7d, 0x89, 0x66, 0xb5, 0xc5, 0x55, 0xaf, 0x58, 0x05, 0x98, 0x9d, 0xa9, 0xfb, 0xf8, 0xdb, + 0x95, 0xe1, 0x56, 0x31, 0xce, 0x35, 0x8c, 0x3a, 0x17, 0x10, 0xc9, 0x62, 0x67, 0x90, 0x63, + ]; + let secret_hash = &[1; 20]; + + let send_payment_args = SendPaymentArgs { + time_lock_duration: 100, + time_lock, + other_pubkey, + secret_hash, + amount: BigDecimal::from_str("10").unwrap(), // 10 USDT + swap_contract_address: &Some(swap_contract().as_bytes().into()), + swap_unique_data: &[], + payment_instructions: &None, + watcher_reward: None, + wait_for_confirmation_until: now_sec() + 60, + }; + let usdt_maker_payment = block_on(usdt_coin.send_maker_payment(send_payment_args)).unwrap(); + log!( + "USDT maker payment tx hash {:02x}", + usdt_maker_payment.tx_hash_as_bytes() + ); + + let confirm_input = ConfirmPaymentInput { + payment_tx: usdt_maker_payment.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: now_sec() + 60, + check_every: 1, + }; + block_on_f01(usdt_coin.wait_for_confirmations(confirm_input)).unwrap(); + + let refund_args = RefundPaymentArgs { + payment_tx: &usdt_maker_payment.tx_hex(), + time_lock, + other_pubkey, + tx_type_with_secret_hash: SwapTxTypeWithSecretHash::TakerOrMakerPayment { + maker_secret_hash: secret_hash, + }, + swap_contract_address: &Some(swap_contract().as_bytes().into()), + swap_unique_data: &[], + watcher_reward: false, + }; + let payment_refund = block_on(usdt_coin.send_maker_refunds_payment(refund_args)).unwrap(); + log!("USDT payment refund tx hash {:02x}", payment_refund.tx_hash_as_bytes()); + + let confirm_input = ConfirmPaymentInput { + payment_tx: payment_refund.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: now_sec() + 60, + check_every: 1, + }; + block_on_f01(usdt_coin.wait_for_confirmations(confirm_input)).unwrap(); + + let search_input = SearchForSwapTxSpendInput { + time_lock, + other_pub: other_pubkey, + secret_hash, + tx: &usdt_maker_payment.tx_hex(), + search_from_block: 0, + swap_contract_address: &Some(swap_contract().as_bytes().into()), + swap_unique_data: &[], + }; + let search_tx = block_on(usdt_coin.search_for_swap_tx_spend_my(search_input)) + .unwrap() + .unwrap(); + + let expected = FoundSwapTxSpend::Refunded(payment_refund); + assert_eq!(expected, search_tx); +} + +#[test] +fn send_and_refund_usdt_maker_payment_legacy_gas_policy() { + send_and_refund_usdt_maker_payment_impl(SwapGasFeePolicy::Legacy); +} + +#[test] +fn send_and_refund_usdt_maker_payment_priority_fee() { + send_and_refund_usdt_maker_payment_impl(SwapGasFeePolicy::Medium); +} + +/// Test that get_erc20_token_info correctly fetches USDT token info from chain, +/// verifying that the non-standard decimals() return type (uint256 instead of uint8) is handled. +/// This is critical because USDT's decimals() returns uint256, not the standard uint8. +#[test] +fn test_usdt_get_token_info() { + // Use ETH coin as web3 provider to query the USDT contract + let eth_coin = eth_coin_with_random_privkey(swap_contract()); + let usdt_address = geth_usdt_contract(); + + // Call get_erc20_token_info which internally calls decimals() on the contract + // This verifies that the uint256 return type from USDT's decimals() is correctly parsed + let token_info = block_on(get_erc20_token_info(ð_coin, usdt_address)).unwrap(); + + assert_eq!(token_info.symbol, "USDT"); + assert_eq!(token_info.decimals, 6); +} diff --git a/mm2src/mm2_main/tests/docker_tests/eth_inner_tests.rs b/mm2src/mm2_main/tests/docker_tests/eth_inner_tests.rs new file mode 100644 index 0000000000..097b22a128 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/eth_inner_tests.rs @@ -0,0 +1,1295 @@ +// ETH Inner Tests +// +// This module contains ETH-only tests that were extracted from docker_tests_inner.rs. +// These tests focus on ETH/ERC20 coin functionality including: +// - ETH/ERC20 activation and disable flows +// - Swap contract address negotiation +// - ETH/ERC20 withdraw and send operations +// - ETH/ERC20 orderbook and order management +// - ERC20 token approval +// +// Gated by: docker-tests-eth + +use crate::docker_tests::helpers::env::random_secp256k1_secret; +use crate::docker_tests::helpers::eth::{ + erc20_coin_with_random_privkey, erc20_contract_checksum, fill_eth_erc20_with_private_key, swap_contract, + swap_contract_checksum, GETH_RPC_URL, MM_CTX, +}; +use crate::docker_tests::helpers::swap::trade_base_rel; +use crate::integration_tests_common::rmd160_from_passphrase; +use coins::{MarketCoinOps, TxFeeDetails}; +use common::{block_on, get_utc_timestamp}; +use crypto::{CryptoCtx, DerivationPath, KeyPairPolicy}; +use http::StatusCode; +use mm2_number::BigDecimal; +use mm2_test_helpers::for_tests::{ + disable_coin, disable_coin_err, enable_eth_coin, erc20_dev_conf, eth_dev_conf, start_swaps, + task_enable_eth_with_tokens, wait_for_swap_contract_negotiation, wait_for_swap_negotiation_failure, MarketMakerIt, + Mm2TestConf, DEFAULT_RPC_PASSWORD, +}; +use mm2_test_helpers::structs::*; +use serde_json::{json, Value as Json}; +use std::collections::HashSet; +use std::iter::FromIterator; +use std::str::FromStr; +use std::thread; +use std::time::Duration; + +// ============================================================================= +// Test address constants +// ============================================================================= + +/// Arbitrary address used for swap contract negotiation tests (maker side) +const TEST_ARBITRARY_SWAP_ADDR_1: &str = "0x6c2858f6afac835c43ffda248aea167e1a58436c"; +/// Arbitrary address used for swap contract negotiation tests (taker side) +const TEST_ARBITRARY_SWAP_ADDR_2: &str = "0x24abe4c71fc658c01313b6552cd40cd808b3ea80"; +/// Valid checksummed ETH address used as withdraw destination in tests +const TEST_WITHDRAW_DEST_ADDR: &str = "0x4b2d0d6c2c785217457B69B922A2A9cEA98f71E9"; +/// Invalid checksum variant of the withdraw destination (for checksum validation tests) +const TEST_WITHDRAW_DEST_ADDR_INVALID_CHECKSUM: &str = "0x4b2d0d6c2c785217457b69b922a2A9cEA98f71E9"; + +// ============================================================================= +// ETH Activation Helper +// ============================================================================= + +async fn enable_eth_with_tokens( + mm: &MarketMakerIt, + platform_coin: &str, + tokens: &[&str], + swap_contract_address: &str, + nodes: &[&str], + balance: bool, +) -> Json { + let erc20_tokens_requests: Vec<_> = tokens.iter().map(|ticker| json!({ "ticker": ticker })).collect(); + let nodes: Vec<_> = nodes.iter().map(|url| json!({ "url": url })).collect(); + + let enable = mm + .rpc(&json!({ + "userpass": mm.userpass, + "method": "enable_eth_with_tokens", + "mmrpc": "2.0", + "params": { + "ticker": platform_coin, + "erc20_tokens_requests": erc20_tokens_requests, + "swap_contract_address": swap_contract_address, + "nodes": nodes, + "tx_history": true, + "get_balances": balance, + } + })) + .await + .unwrap(); + assert_eq!( + enable.0, + StatusCode::OK, + "'enable_eth_with_tokens' failed: {}", + enable.1 + ); + serde_json::from_str(&enable.1).unwrap() +} + +// ============================================================================= +// ETH/ERC20 Activation and Disable Tests +// ============================================================================= + +#[test] +fn test_enable_eth_coin_with_token_then_disable() { + let coin = erc20_coin_with_random_privkey(swap_contract()); + + let priv_key = coin.display_priv_key().unwrap(); + let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); + + let conf = Mm2TestConf::seednode(&priv_key, &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_dump_log, _dump_dashboard) = mm.mm_dump(); + log!("log path: {}", mm.log_path.display()); + + let swap_contract = swap_contract_checksum(); + block_on(enable_eth_with_tokens( + &mm, + "ETH", + &["ERC20DEV"], + &swap_contract, + &[GETH_RPC_URL], + true, + )); + + // Create setprice order + let req = json!({ + "userpass": mm.userpass, + "method": "buy", + "base": "ETH", + "rel": "ERC20DEV", + "price": 1, + "volume": 0.1, + "base_confs": 5, + "base_nota": false, + "rel_confs": 4, + "rel_nota": false, + }); + let make_test_order = block_on(mm.rpc(&req)).unwrap(); + assert_eq!(make_test_order.0, StatusCode::OK); + let order_uuid = Json::from_str(&make_test_order.1).unwrap(); + let order_uuid = order_uuid.get("result").unwrap().get("uuid").unwrap().as_str().unwrap(); + + // Passive ETH while having tokens enabled + let res = block_on(disable_coin(&mm, "ETH", false)); + assert!(res.passivized); + assert!(res.cancelled_orders.contains(order_uuid)); + + // Try to disable ERC20DEV token. + // This should work, because platform coin is still in the memory. + let res = block_on(disable_coin(&mm, "ERC20DEV", false)); + // We expected make_test_order to be cancelled + assert!(!res.passivized); + + // Because it's currently passive, default `disable_coin` should fail. + block_on(disable_coin_err(&mm, "ETH", false)); + // And forced `disable_coin` should not fail + let res = block_on(disable_coin(&mm, "ETH", true)); + assert!(!res.passivized); +} + +#[test] +fn test_platform_coin_mismatch() { + let coin = erc20_coin_with_random_privkey(swap_contract()); + + let priv_key = coin.display_priv_key().unwrap(); + let mut erc20_conf = erc20_dev_conf(&erc20_contract_checksum()); + erc20_conf["protocol"]["protocol_data"]["platform"] = "MATIC".into(); // set a different platform coin + let coins = json!([eth_dev_conf(), erc20_conf]); + + let conf = Mm2TestConf::seednode(&priv_key, &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_dump_log, _dump_dashboard) = mm.mm_dump(); + log!("log path: {}", mm.log_path.display()); + + let swap_contract = swap_contract_checksum(); + let erc20_tokens_requests = vec![json!({ "ticker": "ERC20DEV" })]; + let nodes = vec![json!({ "url": GETH_RPC_URL })]; + + let enable = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "method": "enable_eth_with_tokens", + "mmrpc": "2.0", + "params": { + "ticker": "ETH", + "erc20_tokens_requests": erc20_tokens_requests, + "swap_contract_address": swap_contract, + "nodes": nodes, + "tx_history": false, + "get_balances": false, + } + }))) + .unwrap(); + assert_eq!( + enable.0, + StatusCode::BAD_REQUEST, + "'enable_eth_with_tokens' must fail with PlatformCoinMismatch", + ); + assert_eq!( + serde_json::from_str::(&enable.1).unwrap()["error_type"] + .as_str() + .unwrap(), + "PlatformCoinMismatch", + ); +} + +#[test] +fn test_enable_eth_coin_with_token_without_balance() { + let coin = erc20_coin_with_random_privkey(swap_contract()); + + let priv_key = coin.display_priv_key().unwrap(); + let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); + + let conf = Mm2TestConf::seednode(&priv_key, &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_dump_log, _dump_dashboard) = mm.mm_dump(); + log!("log path: {}", mm.log_path.display()); + + let swap_contract = swap_contract_checksum(); + let enable_eth_with_tokens = block_on(enable_eth_with_tokens( + &mm, + "ETH", + &["ERC20DEV"], + &swap_contract, + &[GETH_RPC_URL], + false, + )); + + let enable_eth_with_tokens: RpcV2Response = + serde_json::from_value(enable_eth_with_tokens).unwrap(); + + let (_, eth_balance) = enable_eth_with_tokens + .result + .eth_addresses_infos + .into_iter() + .next() + .unwrap(); + log!("{:?}", eth_balance); + assert!(eth_balance.balances.is_none()); + assert!(eth_balance.tickers.is_none()); + + let (_, erc20_balances) = enable_eth_with_tokens + .result + .erc20_addresses_infos + .into_iter() + .next() + .unwrap(); + assert!(erc20_balances.balances.is_none()); + assert_eq!( + erc20_balances.tickers.unwrap(), + HashSet::from_iter(vec!["ERC20DEV".to_string()]) + ); +} + +// ============================================================================= +// Swap Contract Negotiation Tests +// ============================================================================= + +#[test] +fn test_eth_swap_contract_addr_negotiation_same_fallback() { + let bob_coin = erc20_coin_with_random_privkey(swap_contract()); + let alice_coin = erc20_coin_with_random_privkey(swap_contract()); + + let bob_priv_key = bob_coin.display_priv_key().unwrap(); + let alice_priv_key = alice_coin.display_priv_key().unwrap(); + + let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); + + let bob_conf = Mm2TestConf::seednode(&bob_priv_key, &coins); + let mut mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); + + let (_bob_dump_log, _bob_dump_dashboard) = mm_bob.mm_dump(); + log!("Bob log path: {}", mm_bob.log_path.display()); + + let alice_conf = Mm2TestConf::light_node(&alice_priv_key, &coins, &[&mm_bob.ip.to_string()]); + let mut mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); + + let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); + log!("Alice log path: {}", mm_alice.log_path.display()); + + let swap_contract = swap_contract_checksum(); + + dbg!(block_on(enable_eth_coin( + &mm_bob, + "ETH", + &[GETH_RPC_URL], + // using arbitrary address + TEST_ARBITRARY_SWAP_ADDR_1, + Some(&swap_contract), + false + ))); + + dbg!(block_on(enable_eth_coin( + &mm_bob, + "ERC20DEV", + &[GETH_RPC_URL], + // using arbitrary address + TEST_ARBITRARY_SWAP_ADDR_1, + Some(&swap_contract), + false + ))); + + dbg!(block_on(enable_eth_coin( + &mm_alice, + "ETH", + &[GETH_RPC_URL], + // using arbitrary address + TEST_ARBITRARY_SWAP_ADDR_2, + Some(&swap_contract), + false + ))); + + dbg!(block_on(enable_eth_coin( + &mm_alice, + "ERC20DEV", + &[GETH_RPC_URL], + // using arbitrary address + TEST_ARBITRARY_SWAP_ADDR_2, + Some(&swap_contract), + false + ))); + + let uuids = block_on(start_swaps( + &mut mm_bob, + &mut mm_alice, + &[("ETH", "ERC20DEV")], + 1., + 1., + 0.0001, + )); + + // give few seconds for swap statuses to be saved + thread::sleep(Duration::from_secs(3)); + + let wait_until = get_utc_timestamp() + 30; + // Expected contract should be lowercase since swap status stores addresses in lowercase format + let expected_contract = Json::from(swap_contract.trim_start_matches("0x").to_lowercase()); + + block_on(wait_for_swap_contract_negotiation( + &mm_bob, + &uuids[0], + expected_contract.clone(), + wait_until, + )); + block_on(wait_for_swap_contract_negotiation( + &mm_alice, + &uuids[0], + expected_contract, + wait_until, + )); +} + +#[test] +fn test_eth_swap_negotiation_fails_maker_no_fallback() { + let bob_coin = erc20_coin_with_random_privkey(swap_contract()); + let alice_coin = erc20_coin_with_random_privkey(swap_contract()); + + let bob_priv_key = bob_coin.display_priv_key().unwrap(); + let alice_priv_key = alice_coin.display_priv_key().unwrap(); + + let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); + + let bob_conf = Mm2TestConf::seednode(&bob_priv_key, &coins); + let mut mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); + + let (_bob_dump_log, _bob_dump_dashboard) = mm_bob.mm_dump(); + log!("Bob log path: {}", mm_bob.log_path.display()); + + let alice_conf = Mm2TestConf::light_node(&alice_priv_key, &coins, &[&mm_bob.ip.to_string()]); + let mut mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); + + let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); + log!("Alice log path: {}", mm_alice.log_path.display()); + + let swap_contract = swap_contract_checksum(); + + dbg!(block_on(enable_eth_coin( + &mm_bob, + "ETH", + &[GETH_RPC_URL], + // using arbitrary address + TEST_ARBITRARY_SWAP_ADDR_1, + None, + false + ))); + + dbg!(block_on(enable_eth_coin( + &mm_bob, + "ERC20DEV", + &[GETH_RPC_URL], + // using arbitrary address + TEST_ARBITRARY_SWAP_ADDR_1, + None, + false + ))); + + dbg!(block_on(enable_eth_coin( + &mm_alice, + "ETH", + &[GETH_RPC_URL], + // using arbitrary address + TEST_ARBITRARY_SWAP_ADDR_2, + Some(&swap_contract), + false + ))); + + dbg!(block_on(enable_eth_coin( + &mm_alice, + "ERC20DEV", + &[GETH_RPC_URL], + // using arbitrary address + TEST_ARBITRARY_SWAP_ADDR_2, + Some(&swap_contract), + false + ))); + + let uuids = block_on(start_swaps( + &mut mm_bob, + &mut mm_alice, + &[("ETH", "ERC20DEV")], + 1., + 1., + 0.0001, + )); + + // give few seconds for swap statuses to be saved + thread::sleep(Duration::from_secs(3)); + + let wait_until = get_utc_timestamp() + 30; + block_on(wait_for_swap_negotiation_failure(&mm_bob, &uuids[0], wait_until)); + block_on(wait_for_swap_negotiation_failure(&mm_alice, &uuids[0], wait_until)); +} + +// ============================================================================= +// ETH/ERC20 Swap Tests +// ============================================================================= + +#[test] +fn test_trade_base_rel_eth_erc20_coins() { + trade_base_rel(("ETH", "ERC20DEV")); +} + +// ============================================================================= +// ETH/ERC20 Withdraw and Send Tests +// ============================================================================= + +fn withdraw_and_send( + mm: &MarketMakerIt, + coin: &str, + from: Option, + to: &str, + from_addr: &str, + expected_bal_change: &str, + amount: f64, +) { + let withdraw = block_on(mm.rpc(&json! ({ + "mmrpc": "2.0", + "userpass": mm.userpass, + "method": "withdraw", + "params": { + "coin": coin, + "from": from, + "to": to, + "amount": amount, + }, + "id": 0, + }))) + .unwrap(); + + assert!(withdraw.0.is_success(), "!withdraw: {}", withdraw.1); + let res: RpcSuccessResponse = + serde_json::from_str(&withdraw.1).expect("Expected 'RpcSuccessResponse'"); + let tx_details = res.result; + + let mut expected_bal_change = BigDecimal::from_str(expected_bal_change).expect("!BigDecimal::from_str"); + + let fee_details: TxFeeDetails = serde_json::from_value(tx_details.fee_details).unwrap(); + + if let TxFeeDetails::Eth(fee_details) = fee_details { + if coin == "ETH" { + expected_bal_change -= fee_details.total_fee; + } + } + + assert_eq!(tx_details.to, vec![to.to_owned()]); + assert_eq!(tx_details.my_balance_change, expected_bal_change); + // Todo: Should check the from address for withdraws from another HD wallet address when there is an RPC method for addresses + if from.is_none() { + assert_eq!(tx_details.from, vec![from_addr.to_owned()]); + } + + let send = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "send_raw_transaction", + "coin": coin, + "tx_hex": tx_details.tx_hex, + }))) + .unwrap(); + assert!(send.0.is_success(), "!{} send: {}", coin, send.1); + let send_json: Json = serde_json::from_str(&send.1).unwrap(); + assert_eq!(tx_details.tx_hash, send_json["tx_hash"]); +} + +#[test] +fn test_withdraw_and_send_eth_erc20() { + let privkey = random_secp256k1_secret(); + fill_eth_erc20_with_private_key(privkey); + + let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); + let mm = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(privkey)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("Alice log path: {}", mm.log_path.display()); + + let swap_contract = swap_contract_checksum(); + let eth_enable = block_on(enable_eth_coin( + &mm, + "ETH", + &[GETH_RPC_URL], + &swap_contract, + None, + false, + )); + let erc20_enable = block_on(enable_eth_coin( + &mm, + "ERC20DEV", + &[GETH_RPC_URL], + &swap_contract, + None, + false, + )); + + withdraw_and_send( + &mm, + "ETH", + None, + TEST_WITHDRAW_DEST_ADDR, + eth_enable["address"].as_str().unwrap(), + "-0.001", + 0.001, + ); + + withdraw_and_send( + &mm, + "ERC20DEV", + None, + TEST_WITHDRAW_DEST_ADDR, + erc20_enable["address"].as_str().unwrap(), + "-0.001", + 0.001, + ); + + // must not allow to withdraw to invalid checksum address + let withdraw = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "withdraw", + "params": { + "coin": "ETH", + "to": TEST_WITHDRAW_DEST_ADDR_INVALID_CHECKSUM, + "amount": "0.001", + }, + "id": 0, + }))) + .unwrap(); + + assert!(withdraw.0.is_client_error(), "ETH withdraw: {}", withdraw.1); + let res: RpcErrorResponse = serde_json::from_str(&withdraw.1).unwrap(); + assert_eq!(res.error_type, "InvalidAddress"); + assert!(res.error.contains("Invalid address checksum")); +} + +#[test] +fn test_withdraw_and_send_hd_eth_erc20() { + const PASSPHRASE: &str = "tank abandon bind salon remove wisdom net size aspect direct source fossil"; + + let KeyPairPolicy::GlobalHDAccount(hd_acc) = CryptoCtx::init_with_global_hd_account(MM_CTX.clone(), PASSPHRASE) + .unwrap() + .key_pair_policy() + .clone() + else { + panic!("Expected 'KeyPairPolicy::GlobalHDAccount'"); + }; + + let swap_contract = swap_contract_checksum(); + + // Withdraw from HD account 0, change address 0, index 1 + let mut path_to_address = HDAccountAddressId { + account_id: 0, + chain: Bip44Chain::External, + address_id: 1, + }; + let path_to_addr_str = "/0'/0/1"; + let path_to_coin: String = serde_json::from_value(eth_dev_conf()["derivation_path"].clone()).unwrap(); + let derivation_path = path_to_coin.clone() + path_to_addr_str; + let derivation_path = DerivationPath::from_str(&derivation_path).unwrap(); + // Get the private key associated with this account and fill it with eth and erc20 token. + let priv_key = hd_acc.derive_secp256k1_secret(&derivation_path).unwrap(); + fill_eth_erc20_with_private_key(priv_key); + + let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); + + let conf = Mm2TestConf::seednode_with_hd_account(PASSPHRASE, &coins); + let mm_hd = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm_hd.mm_dump(); + log!("Alice log path: {}", mm_hd.log_path.display()); + + let eth_enable = block_on(task_enable_eth_with_tokens( + &mm_hd, + "ETH", + &["ERC20DEV"], + Some(&swap_contract), + &[GETH_RPC_URL], + 60, + Some(path_to_address.clone()), + )); + let activation_result = match eth_enable { + EthWithTokensActivationResult::HD(hd) => hd, + _ => panic!("Expected EthWithTokensActivationResult::HD"), + }; + let balance = match activation_result.wallet_balance { + EnableCoinBalanceMap::HD(hd) => hd, + _ => panic!("Expected EnableCoinBalance::HD"), + }; + let account = balance.accounts.first().expect("Expected account at index 0"); + assert_eq!( + account.addresses[1].address, + "0xDe841899aB4A22E23dB21634e54920aDec402397" + ); + assert_eq!(account.addresses[1].balance.len(), 2); + assert_eq!(account.addresses[1].balance.get("ETH").unwrap().spendable, 100.into()); + assert_eq!( + account.addresses[1].balance.get("ERC20DEV").unwrap().spendable, + 100.into() + ); + + withdraw_and_send( + &mm_hd, + "ETH", + Some(path_to_address.clone()), + TEST_WITHDRAW_DEST_ADDR, + &account.addresses[1].address, + "-0.001", + 0.001, + ); + + withdraw_and_send( + &mm_hd, + "ERC20DEV", + Some(path_to_address.clone()), + TEST_WITHDRAW_DEST_ADDR, + &account.addresses[1].address, + "-0.001", + 0.001, + ); + + // Change the address index, the withdrawal should fail. + path_to_address.address_id = 0; + + let withdraw = block_on(mm_hd.rpc(&json! ({ + "mmrpc": "2.0", + "userpass": mm_hd.userpass, + "method": "withdraw", + "params": { + "coin": "ETH", + "from": path_to_address, + "to": TEST_WITHDRAW_DEST_ADDR, + "amount": 0.001, + }, + "id": 0, + }))) + .unwrap(); + assert!(!withdraw.0.is_success(), "!withdraw: {}", withdraw.1); + + // But if we fill it, we should be able to withdraw. + let path_to_addr_str = "/0'/0/0"; + let derivation_path = path_to_coin + path_to_addr_str; + let derivation_path = DerivationPath::from_str(&derivation_path).unwrap(); + let priv_key = hd_acc.derive_secp256k1_secret(&derivation_path).unwrap(); + fill_eth_erc20_with_private_key(priv_key); + + let withdraw = block_on(mm_hd.rpc(&json! ({ + "mmrpc": "2.0", + "userpass": mm_hd.userpass, + "method": "withdraw", + "params": { + "coin": "ETH", + "from": path_to_address, + "to": TEST_WITHDRAW_DEST_ADDR, + "amount": 0.001, + }, + "id": 0, + }))) + .unwrap(); + assert!(withdraw.0.is_success(), "!withdraw: {}", withdraw.1); + + block_on(mm_hd.stop()).unwrap(); +} + +// ============================================================================= +// ETH/ERC20 Order DB Persistence and Conf Settings Tests +// ============================================================================= + +#[test] +fn test_set_price_must_save_order_to_db() { + let private_key_str = erc20_coin_with_random_privkey(swap_contract()) + .display_priv_key() + .unwrap(); + let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); + + let conf = Mm2TestConf::seednode(&private_key_str, &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("MM log path: {}", mm.log_path.display()); + + // Enable coins + let swap_contract = swap_contract_checksum(); + dbg!(block_on(enable_eth_coin( + &mm, + "ETH", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + dbg!(block_on(enable_eth_coin( + &mm, + "ERC20DEV", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + + log!("Issue bob ETH/ERC20DEV sell request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "setprice", + "base": "ETH", + "rel": "ERC20DEV", + "price": 1, + "volume": 0.1 + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + let rc_json: Json = serde_json::from_str(&rc.1).unwrap(); + let uuid: String = serde_json::from_value(rc_json["result"]["uuid"].clone()).unwrap(); + let order_path = mm.folder.join(format!( + "DB/{}/ORDERS/MY/MAKER/{}.json", + hex::encode(rmd160_from_passphrase(&private_key_str)), + uuid + )); + assert!(order_path.exists()); +} + +#[test] +fn test_set_price_conf_settings() { + let private_key_str = erc20_coin_with_random_privkey(swap_contract()) + .display_priv_key() + .unwrap(); + + let coins = json!([eth_dev_conf(),{"coin":"ERC20DEV","name":"erc20dev","protocol":{"type":"ERC20","protocol_data":{"platform":"ETH","contract_address":erc20_contract_checksum()}},"required_confirmations":2},]); + + let conf = Mm2TestConf::seednode(&private_key_str, &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("MM log path: {}", mm.log_path.display()); + + // Enable coins + let swap_contract = swap_contract_checksum(); + dbg!(block_on(enable_eth_coin( + &mm, + "ETH", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + dbg!(block_on(enable_eth_coin( + &mm, + "ERC20DEV", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + + log!("Issue bob sell request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "setprice", + "base": "ETH", + "rel": "ERC20DEV", + "price": 1, + "volume": 0.1, + "base_confs": 5, + "base_nota": true, + "rel_confs": 4, + "rel_nota": false, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + let json: Json = serde_json::from_str(&rc.1).unwrap(); + assert_eq!(json["result"]["conf_settings"]["base_confs"], Json::from(5)); + assert_eq!(json["result"]["conf_settings"]["base_nota"], Json::from(true)); + assert_eq!(json["result"]["conf_settings"]["rel_confs"], Json::from(4)); + assert_eq!(json["result"]["conf_settings"]["rel_nota"], Json::from(false)); + + // must use coin config as defaults if not set in request + log!("Issue bob sell request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "setprice", + "base": "ETH", + "rel": "ERC20DEV", + "price": 1, + "volume": 0.1, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + let json: Json = serde_json::from_str(&rc.1).unwrap(); + assert_eq!(json["result"]["conf_settings"]["base_confs"], Json::from(1)); + assert_eq!(json["result"]["conf_settings"]["base_nota"], Json::from(false)); + assert_eq!(json["result"]["conf_settings"]["rel_confs"], Json::from(2)); + assert_eq!(json["result"]["conf_settings"]["rel_nota"], Json::from(false)); +} + +#[test] +fn test_buy_conf_settings() { + let private_key_str = erc20_coin_with_random_privkey(swap_contract()) + .display_priv_key() + .unwrap(); + + let coins = json!([eth_dev_conf(),{"coin":"ERC20DEV","name":"erc20dev","protocol":{"type":"ERC20","protocol_data":{"platform":"ETH","contract_address":erc20_contract_checksum()}},"required_confirmations":2},]); + + let conf = Mm2TestConf::seednode(&private_key_str, &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("MM log path: {}", mm.log_path.display()); + + // Enable coins + let swap_contract = swap_contract_checksum(); + dbg!(block_on(enable_eth_coin( + &mm, + "ETH", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + dbg!(block_on(enable_eth_coin( + &mm, + "ERC20DEV", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + + log!("Issue bob buy request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "buy", + "base": "ETH", + "rel": "ERC20DEV", + "price": 1, + "volume": 0.1, + "base_confs": 5, + "base_nota": true, + "rel_confs": 4, + "rel_nota": false, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + let json: Json = serde_json::from_str(&rc.1).unwrap(); + assert_eq!(json["result"]["conf_settings"]["base_confs"], Json::from(5)); + assert_eq!(json["result"]["conf_settings"]["base_nota"], Json::from(true)); + assert_eq!(json["result"]["conf_settings"]["rel_confs"], Json::from(4)); + assert_eq!(json["result"]["conf_settings"]["rel_nota"], Json::from(false)); + + // must use coin config as defaults if not set in request + log!("Issue bob buy request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "buy", + "base": "ETH", + "rel": "ERC20DEV", + "price": 1, + "volume": 0.1, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + let json: Json = serde_json::from_str(&rc.1).unwrap(); + assert_eq!(json["result"]["conf_settings"]["base_confs"], Json::from(1)); + assert_eq!(json["result"]["conf_settings"]["base_nota"], Json::from(false)); + assert_eq!(json["result"]["conf_settings"]["rel_confs"], Json::from(2)); + assert_eq!(json["result"]["conf_settings"]["rel_nota"], Json::from(false)); +} + +#[test] +fn test_sell_conf_settings() { + let private_key_str = erc20_coin_with_random_privkey(swap_contract()) + .display_priv_key() + .unwrap(); + + let coins = json!([eth_dev_conf(),{"coin":"ERC20DEV","name":"erc20dev","protocol":{"type":"ERC20","protocol_data":{"platform":"ETH","contract_address":erc20_contract_checksum()}},"required_confirmations":2},]); + + let conf = Mm2TestConf::seednode(&private_key_str, &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("MM log path: {}", mm.log_path.display()); + + // Enable coins + let swap_contract = swap_contract_checksum(); + dbg!(block_on(enable_eth_coin( + &mm, + "ETH", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + dbg!(block_on(enable_eth_coin( + &mm, + "ERC20DEV", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + + log!("Issue bob sell request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "sell", + "base": "ETH", + "rel": "ERC20DEV", + "price": 1, + "volume": 0.1, + "base_confs": 5, + "base_nota": true, + "rel_confs": 4, + "rel_nota": false, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!sell: {}", rc.1); + let json: Json = serde_json::from_str(&rc.1).unwrap(); + assert_eq!(json["result"]["conf_settings"]["base_confs"], Json::from(5)); + assert_eq!(json["result"]["conf_settings"]["base_nota"], Json::from(true)); + assert_eq!(json["result"]["conf_settings"]["rel_confs"], Json::from(4)); + assert_eq!(json["result"]["conf_settings"]["rel_nota"], Json::from(false)); + + // must use coin config as defaults if not set in request + log!("Issue bob sell request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "sell", + "base": "ETH", + "rel": "ERC20DEV", + "price": 1, + "volume": 0.1, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!sell: {}", rc.1); + let json: Json = serde_json::from_str(&rc.1).unwrap(); + assert_eq!(json["result"]["conf_settings"]["base_confs"], Json::from(1)); + assert_eq!(json["result"]["conf_settings"]["base_nota"], Json::from(false)); + assert_eq!(json["result"]["conf_settings"]["rel_confs"], Json::from(2)); + assert_eq!(json["result"]["conf_settings"]["rel_nota"], Json::from(false)); +} + +// ============================================================================= +// ETH/ERC20 Order Matching and my_orders Tests +// ============================================================================= + +#[test] +fn test_my_orders_after_matched() { + let bob_coin = erc20_coin_with_random_privkey(swap_contract()); + let alice_coin = erc20_coin_with_random_privkey(swap_contract()); + + let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); + + let bob_conf = Mm2TestConf::seednode(&bob_coin.display_priv_key().unwrap(), &coins); + let mut mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); + + let (_bob_dump_log, _bob_dump_dashboard) = mm_bob.mm_dump(); + log!("Bob log path: {}", mm_bob.log_path.display()); + + let alice_conf = Mm2TestConf::light_node( + &alice_coin.display_priv_key().unwrap(), + &coins, + &[&mm_bob.ip.to_string()], + ); + let mut mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); + + let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); + log!("Alice log path: {}", mm_alice.log_path.display()); + + let swap_contract = swap_contract_checksum(); + dbg!(block_on(enable_eth_coin( + &mm_bob, + "ETH", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + + dbg!(block_on(enable_eth_coin( + &mm_bob, + "ERC20DEV", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + + dbg!(block_on(enable_eth_coin( + &mm_alice, + "ETH", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + + dbg!(block_on(enable_eth_coin( + &mm_alice, + "ERC20DEV", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + + let rc = block_on(mm_bob.rpc(&json! ({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "ETH", + "rel": "ERC20DEV", + "price": 1, + "volume": 0.000001, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + let rc = block_on(mm_alice.rpc(&json! ({ + "userpass": mm_alice.userpass, + "method": "buy", + "base": "ETH", + "rel": "ERC20DEV", + "price": 1, + "volume": 0.000001, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + + block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop ETH/ERC20DEV"))).unwrap(); + block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop ETH/ERC20DEV"))).unwrap(); + + log!("Issue bob my_orders request"); + let rc = block_on(mm_bob.rpc(&json! ({ + "userpass": mm_bob.userpass, + "method": "my_orders", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!my_orders: {}", rc.1); + + let _: MyOrdersRpcResult = serde_json::from_str(&rc.1).unwrap(); + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} + +#[test] +fn test_update_maker_order_after_matched() { + let bob_coin = erc20_coin_with_random_privkey(swap_contract()); + let alice_coin = erc20_coin_with_random_privkey(swap_contract()); + + let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); + + let bob_conf = Mm2TestConf::seednode(&bob_coin.display_priv_key().unwrap(), &coins); + let mut mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); + + let (_bob_dump_log, _bob_dump_dashboard) = mm_bob.mm_dump(); + log!("Bob log path: {}", mm_bob.log_path.display()); + + let alice_conf = Mm2TestConf::light_node( + &alice_coin.display_priv_key().unwrap(), + &coins, + &[&mm_bob.ip.to_string()], + ); + let mut mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); + + let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); + log!("Alice log path: {}", mm_alice.log_path.display()); + + let swap_contract = swap_contract_checksum(); + dbg!(block_on(enable_eth_coin( + &mm_bob, + "ETH", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + + dbg!(block_on(enable_eth_coin( + &mm_bob, + "ERC20DEV", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + + dbg!(block_on(enable_eth_coin( + &mm_alice, + "ETH", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + + dbg!(block_on(enable_eth_coin( + &mm_alice, + "ERC20DEV", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + + let rc = block_on(mm_bob.rpc(&json! ({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "ETH", + "rel": "ERC20DEV", + "price": 1, + "volume": 0.00002, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + let setprice_json: Json = serde_json::from_str(&rc.1).unwrap(); + let uuid: String = serde_json::from_value(setprice_json["result"]["uuid"].clone()).unwrap(); + + let rc = block_on(mm_alice.rpc(&json! ({ + "userpass": mm_alice.userpass, + "method": "buy", + "base": "ETH", + "rel": "ERC20DEV", + "price": 1, + "volume": 0.00001, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + + block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop ETH/ERC20DEV"))).unwrap(); + block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop ETH/ERC20DEV"))).unwrap(); + + log!("Issue bob update maker order request that should fail because new volume is less than reserved amount"); + let update_maker_order = block_on(mm_bob.rpc(&json! ({ + "userpass": mm_bob.userpass, + "method": "update_maker_order", + "uuid": uuid, + "volume_delta": -0.00002, + }))) + .unwrap(); + assert!( + !update_maker_order.0.is_success(), + "update_maker_order success, but should be error {}", + update_maker_order.1 + ); + + log!("Issue another bob update maker order request"); + let update_maker_order = block_on(mm_bob.rpc(&json! ({ + "userpass": mm_bob.userpass, + "method": "update_maker_order", + "uuid": uuid, + "volume_delta": 0.00001, + }))) + .unwrap(); + assert!( + update_maker_order.0.is_success(), + "!update_maker_order: {}", + update_maker_order.1 + ); + let update_maker_order_json: Json = serde_json::from_str(&update_maker_order.1).unwrap(); + log!("{}", update_maker_order.1); + assert_eq!(update_maker_order_json["result"]["max_base_vol"], Json::from("0.00003")); + + log!("Issue bob my_orders request"); + let rc = block_on(mm_bob.rpc(&json! ({ + "userpass": mm_bob.userpass, + "method": "my_orders", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!my_orders: {}", rc.1); + + let _: MyOrdersRpcResult = serde_json::from_str(&rc.1).unwrap(); + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} + +// ============================================================================= +// ERC20 Token Approval Tests +// ============================================================================= + +#[test] +fn test_approve_erc20() { + let privkey = random_secp256k1_secret(); + fill_eth_erc20_with_private_key(privkey); + + let coins = json!([eth_dev_conf(), erc20_dev_conf(&erc20_contract_checksum())]); + let mm = MarketMakerIt::start( + Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins).conf, + DEFAULT_RPC_PASSWORD.to_string(), + None, + ) + .unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("Node log path: {}", mm.log_path.display()); + + let swap_contract = swap_contract_checksum(); + let _eth_enable = block_on(enable_eth_coin( + &mm, + "ETH", + &[GETH_RPC_URL], + &swap_contract, + None, + false, + )); + let _erc20_enable = block_on(enable_eth_coin( + &mm, + "ERC20DEV", + &[GETH_RPC_URL], + &swap_contract, + None, + false, + )); + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "method":"approve_token", + "mmrpc":"2.0", + "id": 0, + "params":{ + "coin": "ERC20DEV", + "spender": swap_contract, + "amount": BigDecimal::from_str("11.0").unwrap(), + } + }))) + .unwrap(); + assert!(rc.0.is_success(), "approve_token error: {}", rc.1); + let res = serde_json::from_str::(&rc.1).unwrap(); + assert!( + hex::decode(str_strip_0x!(res["result"].as_str().unwrap())).is_ok(), + "approve_token result incorrect" + ); + thread::sleep(Duration::from_secs(5)); + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "method":"get_token_allowance", + "mmrpc":"2.0", + "id": 0, + "params":{ + "coin": "ERC20DEV", + "spender": swap_contract, + } + }))) + .unwrap(); + assert!(rc.0.is_success(), "get_token_allowance error: {}", rc.1); + let res = serde_json::from_str::(&rc.1).unwrap(); + assert_eq!( + BigDecimal::from_str(res["result"].as_str().unwrap()).unwrap(), + BigDecimal::from_str("11.0").unwrap(), + "get_token_allowance result incorrect" + ); + + block_on(mm.stop()).unwrap(); +} diff --git a/mm2src/mm2_main/tests/docker_tests/helpers/docker_ops.rs b/mm2src/mm2_main/tests/docker_tests/helpers/docker_ops.rs new file mode 100644 index 0000000000..70f5177e03 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/helpers/docker_ops.rs @@ -0,0 +1,111 @@ +//! Docker operations for docker tests. +//! +//! This module provides shared infrastructure for docker test helpers: +//! - `CoinDockerOps` trait for coins running in docker containers +//! - Docker compose utilities for container management + +use coins::utxo::coin_daemon_data_dir; +#[cfg(any(feature = "docker-tests-slp", feature = "docker-tests-integration"))] +use coins::utxo::rpc_clients::NativeClient; +use coins::utxo::rpc_clients::{UtxoRpcClientEnum, UtxoRpcClientOps}; +use common::{block_on_f01, now_ms, wait_until_ms}; +use std::process::Command; +use std::thread; +use std::time::Duration; + +use super::env::resolve_compose_container_id; + +// ============================================================================= +// CoinDockerOps trait +// ============================================================================= + +/// Trait for docker coin operations. +/// +/// Provides common functionality for coins running in docker containers, +/// including RPC client access and readiness waiting. +/// +/// Implemented by: +/// - `UtxoAssetDockerOps` (in `helpers::utxo`) +/// - `BchDockerOps` (in `helpers::utxo`) +/// - `ZCoinAssetDockerOps` (in `helpers::zcoin`) +pub trait CoinDockerOps { + /// Get the RPC client for this coin. + fn rpc_client(&self) -> &UtxoRpcClientEnum; + + /// Get the native RPC client, panicking if not native. + /// Only used by BchDockerOps::initialize_slp for SLP token setup. + #[cfg(any(feature = "docker-tests-slp", feature = "docker-tests-integration"))] + fn native_client(&self) -> &NativeClient { + match self.rpc_client() { + UtxoRpcClientEnum::Native(native) => native, + _ => panic!("UtxoRpcClientEnum::Native is expected"), + } + } + + /// Wait until the coin node is ready with expected transaction version. + fn wait_ready(&self, expected_tx_version: i32) { + let timeout = wait_until_ms(120000); + loop { + match block_on_f01(self.rpc_client().get_block_count()) { + Ok(n) => { + if n > 1 { + if let UtxoRpcClientEnum::Native(client) = self.rpc_client() { + let hash = block_on_f01(client.get_block_hash(n)).unwrap(); + let block = block_on_f01(client.get_block(hash)).unwrap(); + let coinbase = block_on_f01(client.get_verbose_transaction(&block.tx[0])).unwrap(); + log!("Coinbase tx {:?} in block {}", coinbase, n); + if coinbase.version == expected_tx_version { + break; + } + } + } + }, + Err(e) => log!("{:?}", e), + } + assert!(now_ms() < timeout, "Test timed out"); + thread::sleep(Duration::from_secs(1)); + } + } +} + +// ============================================================================= +// Docker Compose Utilities +// ============================================================================= + +/// Copy a file from a compose container to the host. +pub fn docker_cp_from_container(container_id: &str, src: &str, dst: &std::path::Path) { + Command::new("docker") + .arg("cp") + .arg(format!("{}:{}", container_id, src)) + .arg(dst) + .status() + .expect("Failed to copy file from compose container"); +} + +/// Wait for a file to exist on the filesystem. +pub fn wait_for_file(path: &std::path::Path, timeout_ms: u64) { + let timeout = wait_until_ms(timeout_ms); + loop { + if path.exists() { + break; + } + assert!(now_ms() < timeout, "Timed out waiting for {:?}", path); + thread::sleep(Duration::from_millis(100)); + } +} + +/// Setup UTXO coin configuration from a docker-compose container. +/// +/// Copies the coin configuration file from the compose container to the local +/// daemon data directory. Used when tests run against pre-started compose nodes +/// rather than testcontainers. +pub fn setup_utxo_conf_for_compose(ticker: &str, service_name: &str) { + let mut conf_path = coin_daemon_data_dir(ticker, true); + std::fs::create_dir_all(&conf_path).unwrap(); + conf_path.push(format!("{ticker}.conf")); + + let container_id = resolve_compose_container_id(service_name); + let src = format!("/data/node_0/{ticker}.conf"); + docker_cp_from_container(&container_id, &src, &conf_path); + wait_for_file(&conf_path, 3000); +} diff --git a/mm2src/mm2_main/tests/docker_tests/helpers/env.rs b/mm2src/mm2_main/tests/docker_tests/helpers/env.rs new file mode 100644 index 0000000000..a4950b7022 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/helpers/env.rs @@ -0,0 +1,192 @@ +//! Environment helpers for docker tests. +//! +//! This module provides: +//! - Docker-compose service name constants +//! - Generic docker node helpers and types + +use testcontainers::{Container, GenericImage}; + +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-watchers-eth", + feature = "docker-tests-qrc20", + feature = "docker-tests-eth", + feature = "docker-tests-sia", + feature = "docker-tests-integration" +))] +use crypto::Secp256k1Secret; +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-watchers-eth", + feature = "docker-tests-qrc20", + feature = "docker-tests-eth", + feature = "docker-tests-sia", + feature = "docker-tests-integration" +))] +use secp256k1::SecretKey; + +// Cell import only needed for SET_BURN_PUBKEY_TO_ALICE +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-slp", + feature = "docker-tests-eth", + feature = "docker-tests-integration" +))] +use std::cell::Cell; + +// ============================================================================= +// Thread-local test flags +// ============================================================================= + +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-slp", + feature = "docker-tests-eth", + feature = "docker-tests-integration" +))] +thread_local! { + /// Set test dex pubkey as Taker (to check DexFee::NoFee) + pub static SET_BURN_PUBKEY_TO_ALICE: Cell = const { Cell::new(false) }; +} + +// ============================================================================= +// Docker-compose service name constants +// ============================================================================= + +// Docker-compose service names (see `.docker/test-nodes.yml`). +// Use service names rather than container names to enable label-based lookup, +// making the code resilient to compose project name changes. + +/// docker-compose service name for Qtum/QRC20 node +#[cfg(feature = "docker-tests-qrc20")] +pub const KDF_QTUM_SERVICE: &str = "qtum"; + +/// docker-compose service name for primary UTXO node MYCOIN +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-sia", + feature = "docker-tests-integration" +))] +pub const KDF_MYCOIN_SERVICE: &str = "mycoin"; + +/// docker-compose service name for secondary UTXO node MYCOIN1 +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-integration" +))] +pub const KDF_MYCOIN1_SERVICE: &str = "mycoin1"; + +/// docker-compose service name for BCH/SLP node FORSLP +#[cfg(any(feature = "docker-tests-slp", feature = "docker-tests-integration"))] +pub const KDF_FORSLP_SERVICE: &str = "forslp"; + +/// docker-compose service name for Zcash-based Zombie node +#[cfg(feature = "docker-tests-zcoin")] +pub const KDF_ZOMBIE_SERVICE: &str = "zombie"; + +/// docker-compose service name for IBC relayer node +#[cfg(any(feature = "docker-tests-tendermint", feature = "docker-tests-integration"))] +pub const KDF_IBC_RELAYER_SERVICE: &str = "ibc-relayer"; + +// ============================================================================= +// Generic docker node struct +// ============================================================================= + +/// A running docker container for testing. +pub struct DockerNode { + #[allow(dead_code)] + pub container: Container, + #[allow(dead_code)] + pub ticker: String, + #[allow(dead_code)] + pub port: u16, +} + +// ============================================================================= +// Utility functions +// ============================================================================= + +/// Generate a random secp256k1 secret key for testing. +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-watchers-eth", + feature = "docker-tests-qrc20", + feature = "docker-tests-eth", + feature = "docker-tests-sia", + feature = "docker-tests-integration" +))] +pub fn random_secp256k1_secret() -> Secp256k1Secret { + let priv_key = SecretKey::new(&mut rand6::thread_rng()); + Secp256k1Secret::from(*priv_key.as_ref()) +} + +// ============================================================================= +// Docker Compose Utilities +// ============================================================================= + +/// Find the container ID for a docker-compose service, independent of project name. +/// +/// Uses label-based lookup (`com.docker.compose.service=`) which works +/// regardless of project name or container_name settings. +/// +/// Note: kept in `helpers::env` so both docker-compose setup helpers and Tendermint helpers +/// can reuse it without extra dependencies. +#[cfg(any( + feature = "docker-tests-tendermint", + feature = "docker-tests-integration", + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-sia", + feature = "docker-tests-slp", + feature = "docker-tests-zcoin" +))] +pub fn resolve_compose_container_id(service_name: &str) -> String { + use std::process::Command; + + let output = Command::new("docker") + .args([ + "ps", + "-q", + "--filter", + &format!("label=com.docker.compose.service={}", service_name), + "--filter", + "status=running", + ]) + .output() + .expect("failed to execute `docker ps`"); + + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .lines() + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .unwrap_or_else(|| { + panic!( + "No running container found for docker-compose service '{}'. \ + Make sure `.docker/test-nodes.yml` is up and containers are started.", + service_name + ) + }) +} diff --git a/mm2src/mm2_main/tests/docker_tests/helpers/eth.rs b/mm2src/mm2_main/tests/docker_tests/helpers/eth.rs new file mode 100644 index 0000000000..f7ef7d611e --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/helpers/eth.rs @@ -0,0 +1,1033 @@ +//! Shared ETH/ERC20 helper functions for docker tests. +//! +//! This module provides: +//! - Global state for Geth contracts and accounts +//! - Address getters and checksum helpers +//! - Funding utilities for ETH and ERC20 tokens +//! - Coin creation helpers +//! - Geth initialization with contract deployment + +#[cfg(any(feature = "docker-tests-eth", feature = "docker-tests-watchers-eth"))] +use crate::docker_tests::helpers::env::random_secp256k1_secret; +use crate::docker_tests::helpers::env::DockerNode; +use coins::eth::addr_from_raw_pubkey; +#[cfg(any(feature = "docker-tests-eth", feature = "docker-tests-watchers-eth"))] +use coins::eth::EthCoin; +use coins::eth::{checksum_address, eth_coin_from_conf_and_request, ERC20_ABI}; +#[cfg(any(feature = "docker-tests-eth", feature = "docker-tests-watchers-eth"))] +use coins::DerivationMethod; +use coins::{lp_coinfind, CoinProtocol, CoinWithDerivationMethod, CoinsContext, PrivKeyBuildPolicy}; +use common::block_on; +use common::custom_futures::timeout::FutureTimerExt; +use crypto::privkey::key_pair_from_seed; +#[cfg(any(feature = "docker-tests-eth", feature = "docker-tests-integration"))] +use crypto::Secp256k1Secret; +use ethabi::Token; +use ethereum_types::{H160 as H160Eth, U256}; +use mm2_core::mm_ctx::{MmArc, MmCtxBuilder}; +use mm2_test_helpers::for_tests::{erc20_dev_conf, eth_dev_conf, usdt_dev_conf}; +use mm2_test_helpers::get_passphrase; +use serde_json::json; +use std::sync::{Mutex, OnceLock}; +use std::thread; +use std::time::Duration; +use testcontainers::runners::SyncRunner; +use testcontainers::{GenericImage, RunnableImage}; +use web3::contract::{Contract, Options}; +use web3::types::{Address, BlockId, BlockNumber, TransactionRequest, H256}; +use web3::{transports::Http, Web3}; + +// ============================================================================= +// Global state - statics for Geth node +// ============================================================================= + +lazy_static! { + /// Web3 instance connected to the Geth dev node + pub static ref GETH_WEB3: Web3 = Web3::new(Http::new(GETH_RPC_URL).unwrap()); + /// Mutex used to prevent nonce re-usage during funding addresses used in tests + pub static ref GETH_NONCE_LOCK: Mutex<()> = Mutex::new(()); + + /// Shared MmArc context for single-instance tests + pub static ref MM_CTX: MmArc = MmCtxBuilder::new() + .with_conf(json!({"coins":[eth_dev_conf()],"use_trading_proto_v2": true})) + .into_mm_arc(); + + /// Second MmCtx instance for Maker/Taker tests using same private keys. + /// + /// When enabling coins for both Maker and Taker, two distinct coin instances are created. + /// Different instances of the same coin should have separate global nonce locks. + /// Using different MmCtx instances assigns Maker and Taker coins to separate CoinsCtx, + /// addressing the "replacement transaction" issue (same nonce for different transactions). + pub static ref MM_CTX1: MmArc = MmCtxBuilder::new() + .with_conf(json!({"use_trading_proto_v2": true})) + .into_mm_arc(); +} + +// ============================================================================= +// OnceLock contract addresses (initialized once in init_geth_node) +// ============================================================================= + +/// The account supplied with ETH on Geth dev node creation +static GETH_ACCOUNT: OnceLock = OnceLock::new(); +/// ERC20 token address on Geth dev node +static GETH_ERC20_CONTRACT: OnceLock = OnceLock::new(); +/// Swap contract address on Geth dev node +static GETH_SWAP_CONTRACT: OnceLock = OnceLock::new(); +/// Maker Swap V2 contract address on Geth dev node +static GETH_MAKER_SWAP_V2: OnceLock = OnceLock::new(); +/// Taker Swap V2 contract address on Geth dev node +static GETH_TAKER_SWAP_V2: OnceLock = OnceLock::new(); +/// Swap contract (with watchers support) address on Geth dev node +static GETH_WATCHERS_SWAP_CONTRACT: OnceLock = OnceLock::new(); +/// ERC721 token address on Geth dev node +static GETH_ERC721_CONTRACT: OnceLock = OnceLock::new(); +/// ERC1155 token address on Geth dev node +static GETH_ERC1155_CONTRACT: OnceLock = OnceLock::new(); +/// NFT Maker Swap V2 contract address on Geth dev node +static GETH_NFT_MAKER_SWAP_V2: OnceLock = OnceLock::new(); +/// USDT contract address on Geth dev node (non-standard ERC20 for SafeERC20 testing) +static GETH_USDT_CONTRACT: OnceLock = OnceLock::new(); + +/// Geth RPC URL +pub static GETH_RPC_URL: &str = "http://127.0.0.1:8545"; + +// ============================================================================= +// Docker image constants +// ============================================================================= + +/// Geth docker image +pub const GETH_DOCKER_IMAGE: &str = "docker.io/ethereum/client-go"; +/// Geth docker image with tag +pub const GETH_DOCKER_IMAGE_WITH_TAG: &str = "docker.io/ethereum/client-go:stable"; + +// ============================================================================= +// Contract bytecode constants +// ============================================================================= + +pub const ERC20_TOKEN_BYTES: &str = include_str!("../../../../mm2_test_helpers/contract_bytes/erc20_token_bytes"); +pub const SWAP_CONTRACT_BYTES: &str = include_str!("../../../../mm2_test_helpers/contract_bytes/swap_contract_bytes"); +pub const WATCHERS_SWAP_CONTRACT_BYTES: &str = + include_str!("../../../../mm2_test_helpers/contract_bytes/watchers_swap_contract_bytes"); +/// https://github.com/KomodoPlatform/etomic-swap/blob/7d4eafd4a408188a95aee78a41f0bf5f9116ffa2/contracts/Erc721Token.sol +pub const ERC721_TEST_TOKEN_BYTES: &str = + include_str!("../../../../mm2_test_helpers/contract_bytes/erc721_test_token_bytes"); +/// https://github.com/KomodoPlatform/etomic-swap/blob/7d4eafd4a408188a95aee78a41f0bf5f9116ffa2/contracts/Erc1155Token.sol +pub const ERC1155_TEST_TOKEN_BYTES: &str = + include_str!("../../../../mm2_test_helpers/contract_bytes/erc1155_test_token_bytes"); +/// https://github.com/KomodoPlatform/etomic-swap/blob/7d4eafd4a408188a95aee78a41f0bf5f9116ffa2/contracts/EtomicSwapMakerNftV2.sol +pub const NFT_MAKER_SWAP_V2_BYTES: &str = + include_str!("../../../../mm2_test_helpers/contract_bytes/nft_maker_swap_v2_bytes"); +/// https://github.com/KomodoPlatform/etomic-swap/blob/7d4eafd4a408188a95aee78a41f0bf5f9116ffa2/contracts/EtomicSwapMakerV2.sol +pub const MAKER_SWAP_V2_BYTES: &str = include_str!("../../../../mm2_test_helpers/contract_bytes/maker_swap_v2_bytes"); +/// https://github.com/KomodoPlatform/etomic-swap/blob/7d4eafd4a408188a95aee78a41f0bf5f9116ffa2/contracts/EtomicSwapTakerV2.sol +pub const TAKER_SWAP_V2_BYTES: &str = include_str!("../../../../mm2_test_helpers/contract_bytes/taker_swap_v2_bytes"); +/// Real USDT mainnet contract bytecode from Etherscan for SafeERC20 testing. +/// This is a non-standard ERC20 where transfer/transferFrom return void instead of bool. +pub const USDT_CONTRACT_BYTES: &str = include_str!("../../../../mm2_test_helpers/contract_bytes/usdt_contract_bytes"); +/// USDT ABI from Etherscan - note the non-standard outputs:[] for transfer/transferFrom +pub const USDT_ABI: &str = include_str!("../../../../mm2_test_helpers/dummy_files/usdt_abi.json"); + +/// Geth dev chain ID used for testing +pub const GETH_DEV_CHAIN_ID: u64 = 1337; + +// ============================================================================= +// Address getters - safe OnceCell access +// ============================================================================= + +/// Get the Geth coinbase account address. +/// Panics if called before `init_geth_node()`. +pub fn geth_account() -> Address { + *GETH_ACCOUNT + .get() + .expect("GETH_ACCOUNT not initialized - call init_geth_node() first") +} + +/// Get the swap contract address. +/// Panics if called before `init_geth_node()`. +pub fn swap_contract() -> Address { + *GETH_SWAP_CONTRACT + .get() + .expect("GETH_SWAP_CONTRACT not initialized - call init_geth_node() first") +} + +/// Get the watchers swap contract address. +/// Panics if called before `init_geth_node()`. +pub fn watchers_swap_contract() -> Address { + *GETH_WATCHERS_SWAP_CONTRACT + .get() + .expect("GETH_WATCHERS_SWAP_CONTRACT not initialized - call init_geth_node() first") +} + +/// Get the ERC20 contract address. +/// Panics if called before `init_geth_node()`. +pub fn erc20_contract() -> Address { + *GETH_ERC20_CONTRACT + .get() + .expect("GETH_ERC20_CONTRACT not initialized - call init_geth_node() first") +} + +/// Get the Maker Swap V2 contract address. +/// Panics if called before `init_geth_node()`. +pub fn geth_maker_swap_v2() -> Address { + *GETH_MAKER_SWAP_V2 + .get() + .expect("GETH_MAKER_SWAP_V2 not initialized - call init_geth_node() first") +} + +/// Get the Taker Swap V2 contract address. +/// Panics if called before `init_geth_node()`. +pub fn geth_taker_swap_v2() -> Address { + *GETH_TAKER_SWAP_V2 + .get() + .expect("GETH_TAKER_SWAP_V2 not initialized - call init_geth_node() first") +} + +/// Get the ERC721 contract address. +/// Panics if called before `init_geth_node()`. +pub fn geth_erc721_contract() -> Address { + *GETH_ERC721_CONTRACT + .get() + .expect("GETH_ERC721_CONTRACT not initialized - call init_geth_node() first") +} + +/// Get the ERC1155 contract address. +/// Panics if called before `init_geth_node()`. +pub fn geth_erc1155_contract() -> Address { + *GETH_ERC1155_CONTRACT + .get() + .expect("GETH_ERC1155_CONTRACT not initialized - call init_geth_node() first") +} + +/// Get the NFT Maker Swap V2 contract address. +/// Panics if called before `init_geth_node()`. +pub fn geth_nft_maker_swap_v2() -> Address { + *GETH_NFT_MAKER_SWAP_V2 + .get() + .expect("GETH_NFT_MAKER_SWAP_V2 not initialized - call init_geth_node() first") +} + +/// Get the USDT contract address. +/// Panics if called before `init_geth_node()`. +pub fn geth_usdt_contract() -> Address { + *GETH_USDT_CONTRACT + .get() + .expect("GETH_USDT_CONTRACT not initialized - call init_geth_node() first") +} + +/// Return ERC20 dev token contract address in checksum format +pub fn erc20_contract_checksum() -> String { + checksum_address(&format!("{:02x}", erc20_contract())) +} + +/// Return USDT contract address in checksum format +#[cfg(any(feature = "docker-tests-eth", feature = "docker-tests-watchers-eth"))] +pub fn usdt_contract_checksum() -> String { + checksum_address(&format!("{:02x}", geth_usdt_contract())) +} + +/// Return swap contract address in checksum format (with 0x prefix) +#[cfg(any( + feature = "docker-tests-eth", + feature = "docker-tests-tendermint", + feature = "docker-tests-integration" +))] +pub fn swap_contract_checksum() -> String { + checksum_address(&format!("{:02x}", swap_contract())) +} + +/// Return watchers swap contract address in checksum format (with 0x prefix) +#[cfg(feature = "docker-tests-watchers")] +pub fn watchers_swap_contract_checksum() -> String { + checksum_address(&format!("{:02x}", watchers_swap_contract())) +} + +// ============================================================================= +// Docker node helpers +// ============================================================================= + +/// Start a Geth docker node for testing. +pub fn geth_docker_node(ticker: &'static str, port: u16) -> DockerNode { + let image = GenericImage::new(GETH_DOCKER_IMAGE, "stable"); + let args = vec!["--dev".into(), "--http".into(), "--http.addr=0.0.0.0".into()]; + let image = RunnableImage::from((image, args)).with_mapped_port((port, port)); + let container = image.start().expect("Failed to start Geth docker node"); + DockerNode { + container, + ticker: ticker.into(), + port, + } +} + +/// Wait for the Geth node to be ready to accept connections. +/// +/// Polls the node's block number endpoint until it responds successfully. +/// Used in compose mode where the node may still be starting up. +pub fn wait_for_geth_node_ready() { + let mut attempts = 0; + loop { + if attempts >= 5 { + panic!("Failed to connect to Geth node after several attempts."); + } + match block_on(GETH_WEB3.eth().block_number().timeout(Duration::from_secs(6))) { + Ok(Ok(block_number)) => { + log!("Geth node is ready, latest block number: {:?}", block_number); + break; + }, + Ok(Err(e)) => { + log!("Failed to connect to Geth node: {:?}, retrying...", e); + }, + Err(_) => { + log!("Connection to Geth node timed out, retrying..."); + }, + } + attempts += 1; + thread::sleep(Duration::from_secs(1)); + } +} + +// ============================================================================= +// Funding utilities - fill test wallets with ETH and tokens +// ============================================================================= + +fn wait_for_confirmation(tx_hash: H256) { + thread::sleep(Duration::from_millis(2000)); + loop { + match block_on(GETH_WEB3.eth().transaction_receipt(tx_hash)) { + Ok(Some(r)) => match r.block_hash { + Some(_) => break, + None => thread::sleep(Duration::from_millis(100)), + }, + _ => { + thread::sleep(Duration::from_millis(100)); + }, + } + } +} + +/// Fill an address with ETH from the Geth coinbase account +pub fn fill_eth(to_addr: Address, amount: U256) { + let _guard = GETH_NONCE_LOCK.lock().unwrap(); + let tx_request = TransactionRequest { + from: geth_account(), + to: Some(to_addr), + gas: None, + gas_price: None, + value: Some(amount), + data: None, + nonce: None, + condition: None, + transaction_type: None, + access_list: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + }; + let tx_hash = block_on(GETH_WEB3.eth().send_transaction(tx_request)).unwrap(); + wait_for_confirmation(tx_hash); +} + +/// Fill an address with ERC20 tokens from the Geth coinbase account +pub fn fill_erc20(to_addr: Address, amount: U256) { + let _guard = GETH_NONCE_LOCK.lock().unwrap(); + let erc20 = Contract::from_json(GETH_WEB3.eth(), erc20_contract(), ERC20_ABI.as_bytes()).unwrap(); + + let tx_hash = block_on(erc20.call( + "transfer", + (Token::Address(to_addr), Token::Uint(amount)), + geth_account(), + Options::default(), + )) + .unwrap(); + wait_for_confirmation(tx_hash); +} + +/// Fill an address with USDT tokens from the Geth coinbase account. +/// Note: USDT's transfer() doesn't return a value, so we use the USDT ABI. +pub fn fill_usdt(to_addr: Address, amount: U256) { + let _guard = GETH_NONCE_LOCK.lock().unwrap(); + let usdt = Contract::from_json(GETH_WEB3.eth(), geth_usdt_contract(), USDT_ABI.as_bytes()).unwrap(); + + let tx_hash = block_on(usdt.call( + "transfer", + (Token::Address(to_addr), Token::Uint(amount)), + geth_account(), + Options::default(), + )) + .unwrap(); + wait_for_confirmation(tx_hash); +} + +// ============================================================================= +// Coin creation utilities - create test coins with random keys +// Only used by docker-tests-eth and docker-tests-watchers-eth (not integration) +// ============================================================================= + +/// Creates ETH protocol coin supplied with 100 ETH +#[cfg(any(feature = "docker-tests-eth", feature = "docker-tests-watchers-eth"))] +pub fn eth_coin_with_random_privkey_using_urls(swap_contract_address: Address, urls: &[&str]) -> EthCoin { + let eth_conf = eth_dev_conf(); + let req = json!({ + "method": "enable", + "coin": "ETH", + "swap_contract_address": swap_contract_address, + "urls": urls, + }); + + let secret = random_secp256k1_secret(); + let eth_coin = block_on(eth_coin_from_conf_and_request( + &MM_CTX, + "ETH", + ð_conf, + &req, + CoinProtocol::ETH { + chain_id: GETH_DEV_CHAIN_ID, + }, + PrivKeyBuildPolicy::IguanaPrivKey(secret), + )) + .unwrap(); + + let my_address = match eth_coin.derivation_method() { + DerivationMethod::SingleAddress(addr) => addr.inner(), + _ => panic!("Expected single address"), + }; + + // 100 ETH + fill_eth(my_address, U256::from(10).pow(U256::from(20))); + + eth_coin +} + +/// Creates ETH protocol coin supplied with 100 ETH, using the default GETH_RPC_URL +#[cfg(any(feature = "docker-tests-eth", feature = "docker-tests-watchers-eth"))] +pub fn eth_coin_with_random_privkey(swap_contract_address: Address) -> EthCoin { + eth_coin_with_random_privkey_using_urls(swap_contract_address, &[GETH_RPC_URL]) +} + +/// Creates ERC20 protocol coin supplied with 1 ETH and 100 tokens +#[cfg(any(feature = "docker-tests-eth", feature = "docker-tests-watchers-eth"))] +pub fn erc20_coin_with_random_privkey(swap_contract_address: Address) -> EthCoin { + let secret = random_secp256k1_secret(); + + // Register platform ETH coin if not already registered by another parallel test, so platform_coin() lookups work. + if block_on(lp_coinfind(&MM_CTX, "ETH")).ok().flatten().is_none() { + let eth_conf = eth_dev_conf(); + let eth_req = json!({ + "method": "enable", + "coin": "ETH", + "swap_contract_address": swap_contract_address, + "urls": [GETH_RPC_URL], + }); + let platform_coin = block_on(eth_coin_from_conf_and_request( + &MM_CTX, + "ETH", + ð_conf, + ð_req, + CoinProtocol::ETH { + chain_id: GETH_DEV_CHAIN_ID, + }, + PrivKeyBuildPolicy::IguanaPrivKey(secret), + )) + .unwrap(); + let coins_ctx = CoinsContext::from_ctx(&MM_CTX).unwrap(); + // Ignore error if another parallel test already registered the platform + let _ = block_on(coins_ctx.add_platform_with_tokens(platform_coin.into(), vec![], None)); + } + + // Now create the ERC20 token + let erc20_conf = erc20_dev_conf(&erc20_contract_checksum()); + let req = json!({ + "method": "enable", + "coin": "ERC20DEV", + "swap_contract_address": swap_contract_address, + "urls": [GETH_RPC_URL], + }); + + let erc20_coin = block_on(eth_coin_from_conf_and_request( + &MM_CTX, + "ERC20DEV", + &erc20_conf, + &req, + CoinProtocol::ERC20 { + platform: "ETH".to_string(), + contract_address: checksum_address(&format!("{:02x}", erc20_contract())), + }, + PrivKeyBuildPolicy::IguanaPrivKey(secret), + )) + .unwrap(); + + let my_address = match erc20_coin.derivation_method() { + DerivationMethod::SingleAddress(addr) => addr.inner(), + _ => panic!("Expected single address"), + }; + + // 1 ETH + fill_eth(my_address, U256::from(10).pow(U256::from(18))); + // 100 tokens (it has 8 decimals) + fill_erc20(my_address, U256::from(10000000000u64)); + + erc20_coin +} + +/// Creates USDT protocol coin supplied with 1 ETH and 100 USDT. +/// Uses the real USDT contract (non-standard ERC20) for SafeERC20 testing. +#[cfg(any(feature = "docker-tests-eth", feature = "docker-tests-watchers-eth"))] +pub fn usdt_coin_with_random_privkey(swap_contract_address: Address) -> EthCoin { + let secret = random_secp256k1_secret(); + + // Register platform ETH coin if not already registered by another parallel test, so platform_coin() lookups work. + if block_on(lp_coinfind(&MM_CTX, "ETH")).ok().flatten().is_none() { + let eth_conf = eth_dev_conf(); + let eth_req = json!({ + "method": "enable", + "coin": "ETH", + "swap_contract_address": swap_contract_address, + "urls": [GETH_RPC_URL], + }); + let platform_coin = block_on(eth_coin_from_conf_and_request( + &MM_CTX, + "ETH", + ð_conf, + ð_req, + CoinProtocol::ETH { + chain_id: GETH_DEV_CHAIN_ID, + }, + PrivKeyBuildPolicy::IguanaPrivKey(secret), + )) + .unwrap(); + let coins_ctx = CoinsContext::from_ctx(&MM_CTX).unwrap(); + // Ignore error if another parallel test already registered the platform + let _ = block_on(coins_ctx.add_platform_with_tokens(platform_coin.into(), vec![], None)); + } + + // Now create the USDT token + let usdt_conf = usdt_dev_conf(&usdt_contract_checksum()); + let req = json!({ + "method": "enable", + "coin": "USDT", + "swap_contract_address": swap_contract_address, + "urls": [GETH_RPC_URL], + }); + + let usdt_coin = block_on(eth_coin_from_conf_and_request( + &MM_CTX, + "USDT", + &usdt_conf, + &req, + CoinProtocol::ERC20 { + platform: "ETH".to_string(), + contract_address: usdt_contract_checksum(), + }, + PrivKeyBuildPolicy::IguanaPrivKey(secret), + )) + .unwrap(); + + let my_address = match usdt_coin.derivation_method() { + DerivationMethod::SingleAddress(addr) => addr.inner(), + _ => panic!("Expected single address"), + }; + + // 1 ETH for gas + fill_eth(my_address, U256::from(10).pow(U256::from(18))); + // 100 USDT (6 decimals) + fill_usdt(my_address, U256::from(100_000_000u64)); + + usdt_coin +} + +/// Fills the private key's public address with ETH and ERC20 tokens +#[cfg(any(feature = "docker-tests-eth", feature = "docker-tests-integration"))] +pub fn fill_eth_erc20_with_private_key(priv_key: Secp256k1Secret) { + let eth_conf = eth_dev_conf(); + let req = json!({ + "coin": "ETH", + "urls": [GETH_RPC_URL], + "swap_contract_address": swap_contract(), + }); + + let eth_coin = block_on(eth_coin_from_conf_and_request( + &MM_CTX, + "ETH", + ð_conf, + &req, + CoinProtocol::ETH { + chain_id: GETH_DEV_CHAIN_ID, + }, + PrivKeyBuildPolicy::IguanaPrivKey(priv_key), + )) + .unwrap(); + let my_address = block_on(eth_coin.derivation_method().single_addr_or_err()).unwrap(); + + // 100 ETH + fill_eth(my_address.inner(), U256::from(10).pow(U256::from(20))); + + let erc20_conf = erc20_dev_conf(&erc20_contract_checksum()); + let req = json!({ + "method": "enable", + "coin": "ERC20DEV", + "urls": [GETH_RPC_URL], + "swap_contract_address": swap_contract(), + }); + + let _erc20_coin = block_on(eth_coin_from_conf_and_request( + &MM_CTX, + "ERC20DEV", + &erc20_conf, + &req, + CoinProtocol::ERC20 { + platform: "ETH".to_string(), + contract_address: erc20_contract_checksum(), + }, + PrivKeyBuildPolicy::IguanaPrivKey(priv_key), + )) + .unwrap(); + + // 100 tokens (it has 8 decimals) + fill_erc20(my_address.inner(), U256::from(10000000000u64)); +} + +// ============================================================================= +// Geth initialization +// ============================================================================= + +async fn get_current_gas_limit(web3: &Web3) { + match web3.eth().block(BlockId::Number(BlockNumber::Latest)).await { + Ok(Some(block)) => { + log!("Current gas limit: {}", block.gas_limit); + }, + Ok(None) => log!("Latest block information is not available."), + Err(e) => log!("Failed to fetch the latest block: {}", e), + } +} + +/// Initialize the Geth node by deploying all test contracts. +/// +/// This function deploys: +/// - ERC20 test token +/// - Swap contract +/// - Maker/Taker Swap V2 contracts +/// - Watchers swap contract +/// - NFT Maker Swap V2 contract +/// - ERC721 and ERC1155 test tokens +/// +/// It also funds the Alice and Bob test accounts with ETH. +pub fn init_geth_node() { + block_on(get_current_gas_limit(&GETH_WEB3)); + let gas_price = block_on(GETH_WEB3.eth().gas_price()).unwrap(); + log!("Current gas price: {:?}", gas_price); + let accounts = block_on(GETH_WEB3.eth().accounts()).unwrap(); + let geth_account = accounts[0]; + GETH_ACCOUNT + .set(geth_account) + .expect("GETH_ACCOUNT already initialized"); + log!("GETH ACCOUNT {:?}", geth_account); + + let tx_request_deploy_erc20 = TransactionRequest { + from: geth_account, + to: None, + gas: None, + gas_price: None, + value: None, + data: Some(hex::decode(ERC20_TOKEN_BYTES).unwrap().into()), + nonce: None, + condition: None, + transaction_type: None, + access_list: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + }; + + let deploy_erc20_tx_hash = block_on(GETH_WEB3.eth().send_transaction(tx_request_deploy_erc20)).unwrap(); + log!("Sent ERC20 deploy transaction {:?}", deploy_erc20_tx_hash); + + let geth_erc20_contract = loop { + let deploy_tx_receipt = match block_on(GETH_WEB3.eth().transaction_receipt(deploy_erc20_tx_hash)) { + Ok(receipt) => receipt, + Err(_) => { + thread::sleep(Duration::from_millis(100)); + continue; + }, + }; + + if let Some(receipt) = deploy_tx_receipt { + let addr = receipt.contract_address.unwrap(); + log!("GETH_ERC20_CONTRACT {:?}", addr); + break addr; + } + thread::sleep(Duration::from_millis(100)); + }; + GETH_ERC20_CONTRACT + .set(geth_erc20_contract) + .expect("GETH_ERC20_CONTRACT already initialized"); + + let tx_request_deploy_swap_contract = TransactionRequest { + from: geth_account, + to: None, + gas: None, + gas_price: None, + value: None, + data: Some(hex::decode(SWAP_CONTRACT_BYTES).unwrap().into()), + nonce: None, + condition: None, + transaction_type: None, + access_list: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + }; + let deploy_swap_tx_hash = block_on(GETH_WEB3.eth().send_transaction(tx_request_deploy_swap_contract)).unwrap(); + log!("Sent deploy swap contract transaction {:?}", deploy_swap_tx_hash); + + let geth_swap_contract = loop { + let deploy_swap_tx_receipt = match block_on(GETH_WEB3.eth().transaction_receipt(deploy_swap_tx_hash)) { + Ok(receipt) => receipt, + Err(_) => { + thread::sleep(Duration::from_millis(100)); + continue; + }, + }; + + if let Some(receipt) = deploy_swap_tx_receipt { + let addr = receipt.contract_address.unwrap(); + log!("GETH_SWAP_CONTRACT {:?}", addr); + break addr; + } + thread::sleep(Duration::from_millis(100)); + }; + GETH_SWAP_CONTRACT + .set(geth_swap_contract) + .expect("GETH_SWAP_CONTRACT already initialized"); + + let tx_request_deploy_maker_swap_contract_v2 = TransactionRequest { + from: geth_account, + to: None, + gas: None, + gas_price: None, + value: None, + data: Some(hex::decode(MAKER_SWAP_V2_BYTES).unwrap().into()), + nonce: None, + condition: None, + transaction_type: None, + access_list: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + }; + let deploy_maker_swap_v2_tx_hash = block_on( + GETH_WEB3 + .eth() + .send_transaction(tx_request_deploy_maker_swap_contract_v2), + ) + .unwrap(); + log!( + "Sent deploy maker swap v2 contract transaction {:?}", + deploy_maker_swap_v2_tx_hash + ); + + let geth_maker_swap_v2 = loop { + let deploy_maker_swap_v2_tx_receipt = + match block_on(GETH_WEB3.eth().transaction_receipt(deploy_maker_swap_v2_tx_hash)) { + Ok(receipt) => receipt, + Err(_) => { + thread::sleep(Duration::from_millis(100)); + continue; + }, + }; + + if let Some(receipt) = deploy_maker_swap_v2_tx_receipt { + let addr = receipt.contract_address.unwrap(); + log!( + "GETH_MAKER_SWAP_V2 contract address: {:?}, receipt.status: {:?}", + addr, + receipt.status + ); + break addr; + } + thread::sleep(Duration::from_millis(100)); + }; + GETH_MAKER_SWAP_V2 + .set(geth_maker_swap_v2) + .expect("GETH_MAKER_SWAP_V2 already initialized"); + + let dex_fee_addr = Token::Address(geth_account); + let params = ethabi::encode(&[dex_fee_addr]); + let taker_swap_v2_data = format!("{}{}", TAKER_SWAP_V2_BYTES, hex::encode(params)); + + let tx_request_deploy_taker_swap_contract_v2 = TransactionRequest { + from: geth_account, + to: None, + gas: None, + gas_price: None, + value: None, + data: Some(hex::decode(taker_swap_v2_data).unwrap().into()), + nonce: None, + condition: None, + transaction_type: None, + access_list: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + }; + let deploy_taker_swap_v2_tx_hash = block_on( + GETH_WEB3 + .eth() + .send_transaction(tx_request_deploy_taker_swap_contract_v2), + ) + .unwrap(); + log!( + "Sent deploy taker swap v2 contract transaction {:?}", + deploy_taker_swap_v2_tx_hash + ); + + let geth_taker_swap_v2 = loop { + let deploy_taker_swap_v2_tx_receipt = + match block_on(GETH_WEB3.eth().transaction_receipt(deploy_taker_swap_v2_tx_hash)) { + Ok(receipt) => receipt, + Err(_) => { + thread::sleep(Duration::from_millis(100)); + continue; + }, + }; + + if let Some(receipt) = deploy_taker_swap_v2_tx_receipt { + let addr = receipt.contract_address.unwrap(); + log!( + "GETH_TAKER_SWAP_V2 contract address: {:?}, receipt.status: {:?}", + addr, + receipt.status + ); + break addr; + } + thread::sleep(Duration::from_millis(100)); + }; + GETH_TAKER_SWAP_V2 + .set(geth_taker_swap_v2) + .expect("GETH_TAKER_SWAP_V2 already initialized"); + + let tx_request_deploy_watchers_swap_contract = TransactionRequest { + from: geth_account, + to: None, + gas: None, + gas_price: None, + value: None, + data: Some(hex::decode(WATCHERS_SWAP_CONTRACT_BYTES).unwrap().into()), + nonce: None, + condition: None, + transaction_type: None, + access_list: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + }; + let deploy_watchers_swap_tx_hash = block_on( + GETH_WEB3 + .eth() + .send_transaction(tx_request_deploy_watchers_swap_contract), + ) + .unwrap(); + log!( + "Sent deploy watchers swap contract transaction {:?}", + deploy_watchers_swap_tx_hash + ); + + let geth_watchers_swap_contract = loop { + let deploy_watchers_swap_tx_receipt = + match block_on(GETH_WEB3.eth().transaction_receipt(deploy_watchers_swap_tx_hash)) { + Ok(receipt) => receipt, + Err(_) => { + thread::sleep(Duration::from_millis(100)); + continue; + }, + }; + + if let Some(receipt) = deploy_watchers_swap_tx_receipt { + let addr = receipt.contract_address.unwrap(); + log!("GETH_WATCHERS_SWAP_CONTRACT {:?}", addr); + break addr; + } + thread::sleep(Duration::from_millis(100)); + }; + GETH_WATCHERS_SWAP_CONTRACT + .set(geth_watchers_swap_contract) + .expect("GETH_WATCHERS_SWAP_CONTRACT already initialized"); + + let tx_request_deploy_nft_maker_swap_v2_contract = TransactionRequest { + from: geth_account, + to: None, + gas: None, + gas_price: None, + value: None, + data: Some(hex::decode(NFT_MAKER_SWAP_V2_BYTES).unwrap().into()), + nonce: None, + condition: None, + transaction_type: None, + access_list: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + }; + let deploy_nft_maker_swap_v2_tx_hash = block_on( + GETH_WEB3 + .eth() + .send_transaction(tx_request_deploy_nft_maker_swap_v2_contract), + ) + .unwrap(); + log!( + "Sent deploy nft maker swap v2 contract transaction {:?}", + deploy_nft_maker_swap_v2_tx_hash + ); + + let geth_nft_maker_swap_v2 = loop { + let deploy_nft_maker_swap_v2_tx_receipt = + match block_on(GETH_WEB3.eth().transaction_receipt(deploy_nft_maker_swap_v2_tx_hash)) { + Ok(receipt) => receipt, + Err(_) => { + thread::sleep(Duration::from_millis(100)); + continue; + }, + }; + + if let Some(receipt) = deploy_nft_maker_swap_v2_tx_receipt { + let addr = receipt.contract_address.unwrap(); + log!( + "GETH_NFT_MAKER_SWAP_V2 contact address: {:?}, receipt.status: {:?}", + addr, + receipt.status + ); + break addr; + } + thread::sleep(Duration::from_millis(100)); + }; + GETH_NFT_MAKER_SWAP_V2 + .set(geth_nft_maker_swap_v2) + .expect("GETH_NFT_MAKER_SWAP_V2 already initialized"); + + let name = Token::String("MyNFT".into()); + let symbol = Token::String("MNFT".into()); + let params = ethabi::encode(&[name, symbol]); + let erc721_data = format!("{}{}", ERC721_TEST_TOKEN_BYTES, hex::encode(params)); + + let tx_request_deploy_erc721 = TransactionRequest { + from: geth_account, + to: None, + gas: None, + gas_price: None, + value: None, + data: Some(hex::decode(erc721_data).unwrap().into()), + nonce: None, + condition: None, + transaction_type: None, + access_list: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + }; + let deploy_erc721_tx_hash = block_on(GETH_WEB3.eth().send_transaction(tx_request_deploy_erc721)).unwrap(); + log!("Sent ERC721 deploy transaction {:?}", deploy_erc721_tx_hash); + + let geth_erc721_contract = loop { + let deploy_erc721_tx_receipt = match block_on(GETH_WEB3.eth().transaction_receipt(deploy_erc721_tx_hash)) { + Ok(receipt) => receipt, + Err(_) => { + thread::sleep(Duration::from_millis(100)); + continue; + }, + }; + + if let Some(receipt) = deploy_erc721_tx_receipt { + let addr = receipt.contract_address.unwrap(); + log!("GETH_ERC721_CONTRACT {:?}", addr); + break addr; + } + thread::sleep(Duration::from_millis(100)); + }; + GETH_ERC721_CONTRACT + .set(geth_erc721_contract) + .expect("GETH_ERC721_CONTRACT already initialized"); + + let uri = Token::String("MyNFTUri".into()); + let params = ethabi::encode(&[uri]); + let erc1155_data = format!("{}{}", ERC1155_TEST_TOKEN_BYTES, hex::encode(params)); + + let tx_request_deploy_erc1155 = TransactionRequest { + from: geth_account, + to: None, + gas: None, + gas_price: None, + value: None, + data: Some(hex::decode(erc1155_data).unwrap().into()), + nonce: None, + condition: None, + transaction_type: None, + access_list: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + }; + let deploy_erc1155_tx_hash = block_on(GETH_WEB3.eth().send_transaction(tx_request_deploy_erc1155)).unwrap(); + log!("Sent ERC1155 deploy transaction {:?}", deploy_erc1155_tx_hash); + + let geth_erc1155_contract = loop { + let deploy_erc1155_tx_receipt = match block_on(GETH_WEB3.eth().transaction_receipt(deploy_erc1155_tx_hash)) { + Ok(receipt) => receipt, + Err(_) => { + thread::sleep(Duration::from_millis(100)); + continue; + }, + }; + + if let Some(receipt) = deploy_erc1155_tx_receipt { + let addr = receipt.contract_address.unwrap(); + log!("GETH_ERC1155_CONTRACT {:?}", addr); + break addr; + } + thread::sleep(Duration::from_millis(100)); + }; + GETH_ERC1155_CONTRACT + .set(geth_erc1155_contract) + .expect("GETH_ERC1155_CONTRACT already initialized"); + + // Deploy USDT contract (non-standard ERC20 for SafeERC20 testing). + // Note: USDT bytecode already includes constructor args for initialSupply=100000 USDT, + // name="Tether USD", symbol="USDT", decimals=6. + let tx_request_deploy_usdt = TransactionRequest { + from: geth_account, + to: None, + // Explicit gas limit for large contract deployment + gas: Some(U256::from(8_000_000u64)), + gas_price: None, + value: None, + data: Some(hex::decode(USDT_CONTRACT_BYTES).unwrap().into()), + nonce: None, + condition: None, + transaction_type: None, + access_list: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + }; + let deploy_usdt_tx_hash = block_on(GETH_WEB3.eth().send_transaction(tx_request_deploy_usdt)).unwrap(); + log!("Sent USDT deploy transaction {:?}", deploy_usdt_tx_hash); + + let geth_usdt_contract = loop { + let deploy_usdt_tx_receipt = match block_on(GETH_WEB3.eth().transaction_receipt(deploy_usdt_tx_hash)) { + Ok(receipt) => receipt, + Err(_) => { + thread::sleep(Duration::from_millis(100)); + continue; + }, + }; + + if let Some(receipt) = deploy_usdt_tx_receipt { + let addr = receipt.contract_address.unwrap(); + log!("GETH_USDT_CONTRACT {:?}", addr); + break addr; + } + thread::sleep(Duration::from_millis(100)); + }; + GETH_USDT_CONTRACT + .set(geth_usdt_contract) + .expect("GETH_USDT_CONTRACT already initialized"); + + let alice_passphrase = get_passphrase!(".env.client", "ALICE_PASSPHRASE").unwrap(); + let alice_keypair = key_pair_from_seed(&alice_passphrase).unwrap(); + let alice_eth_addr = addr_from_raw_pubkey(alice_keypair.public()).unwrap(); + // 100 ETH + fill_eth(alice_eth_addr, U256::from(10).pow(U256::from(20))); + + let bob_passphrase = get_passphrase!(".env.seed", "BOB_PASSPHRASE").unwrap(); + let bob_keypair = key_pair_from_seed(&bob_passphrase).unwrap(); + let bob_eth_addr = addr_from_raw_pubkey(bob_keypair.public()).unwrap(); + // 100 ETH + fill_eth(bob_eth_addr, U256::from(10).pow(U256::from(20))); +} diff --git a/mm2src/mm2_main/tests/docker_tests/helpers/mod.rs b/mm2src/mm2_main/tests/docker_tests/helpers/mod.rs new file mode 100644 index 0000000000..7680138527 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/helpers/mod.rs @@ -0,0 +1,85 @@ +//! Shared helper functions for docker tests. +//! +//! These helpers are organized by chain type and are gated on `run-docker-tests`. +//! +//! ## Module organization +//! +//! - `docker_ops` - Docker operations trait and funding locks for coins in containers +//! - `env` - Environment setup: shared contexts, service constants +//! - `eth` - Ethereum/ERC20: Geth initialization, contract deployment, funding +//! - `utxo` - UTXO coins: MYCOIN, MYCOIN1, BCH/SLP helpers +//! - `qrc20` - Qtum/QRC20: contract initialization, coin creation +//! - `sia` - Sia: node setup, RPC configuration +//! - `swap` - Cross-chain swap orchestration helpers +//! - `tendermint` - Cosmos/Tendermint: node setup, IBC channels +//! - `zcoin` - ZCoin/Zombie: sapling cache, node setup + +// Docker-specific helpers, only needed when docker tests are enabled. +// Gated on specific features to avoid unused code warnings. + +// docker_ops - CoinDockerOps trait and UTXO compose utilities +// (tendermint uses resolve_compose_container_id from env.rs instead) +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-sia", + feature = "docker-tests-slp", + feature = "docker-tests-zcoin", + feature = "docker-tests-integration" +))] +pub mod docker_ops; + +// Environment helpers +#[cfg(feature = "run-docker-tests")] +pub mod env; + +// ETH helpers +#[cfg(any( + feature = "docker-tests-eth", + feature = "docker-tests-watchers-eth", + feature = "docker-tests-integration", +))] +pub mod eth; + +// QRC20 helpers (Qtum/QRC20 docker nodes & contracts). +#[cfg(feature = "docker-tests-qrc20")] +pub mod qrc20; + +// Sia helpers (Sia docker nodes). +#[cfg(feature = "docker-tests-sia")] +pub mod sia; + +// SLP helpers (BCH/SLP tokens). +#[cfg(any(feature = "docker-tests-slp", feature = "docker-tests-integration"))] +pub mod slp; + +// Cross-chain swap orchestration helpers. +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-eth", + feature = "docker-tests-qrc20", + feature = "docker-tests-slp" +))] +pub mod swap; + +// Tendermint / IBC helpers. +#[cfg(any(feature = "docker-tests-tendermint", feature = "docker-tests-integration"))] +pub mod tendermint; + +// UTXO helpers (MYCOIN, MYCOIN1). +// Note: SLP has its own self-contained module (slp.rs) and doesn't need utxo. +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-sia", + feature = "docker-tests-integration" +))] +pub mod utxo; + +// ZCoin/Zombie helpers. +#[cfg(feature = "docker-tests-zcoin")] +pub mod zcoin; diff --git a/mm2src/mm2_main/tests/docker_tests/helpers/qrc20.rs b/mm2src/mm2_main/tests/docker_tests/helpers/qrc20.rs new file mode 100644 index 0000000000..db73709145 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/helpers/qrc20.rs @@ -0,0 +1,524 @@ +//! Qtum/QRC20 helpers for docker tests. +//! +//! This module provides: +//! - QRC20 coin creation and funding utilities +//! - Qtum docker node helpers +//! - QRC20 contract initialization + +use crate::docker_tests::helpers::docker_ops::{docker_cp_from_container, wait_for_file}; +use crate::docker_tests::helpers::env::{ + random_secp256k1_secret, resolve_compose_container_id, DockerNode, KDF_QTUM_SERVICE, +}; +use crate::docker_tests::helpers::utxo::fill_address; +use crate::docker_tests::helpers::utxo::QTUM_LOCK; +use coins::qrc20::rpc_clients::for_tests::Qrc20NativeWalletOps; +use coins::qrc20::{qrc20_coin_with_priv_key, Qrc20ActivationParams, Qrc20Coin}; +use coins::utxo::qtum::QtumBasedCoin; +use coins::utxo::qtum::{qtum_coin_with_priv_key, QtumCoin}; +use coins::utxo::rpc_clients::{UtxoRpcClientEnum, UtxoRpcClientOps}; +use coins::utxo::{sat_from_big_decimal, UtxoActivationParams, UtxoCoinFields}; +use coins::{ConfirmPaymentInput, MarketCoinOps}; +use common::{block_on, block_on_f01, now_ms, now_sec, temp_dir, wait_until_ms, wait_until_sec}; +use crypto::Secp256k1Secret; +use ethereum_types::H160 as H160Eth; +use http::StatusCode; +use mm2_core::mm_ctx::{MmArc, MmCtxBuilder}; +use mm2_number::BigDecimal; +use mm2_test_helpers::for_tests::MarketMakerIt; +use serde_json::{self as json, json, Value as Json}; +use std::path::PathBuf; +use std::process::Command; +use std::str::FromStr; +use std::sync::{Mutex, OnceLock}; +use std::thread; +use std::time::Duration; +use testcontainers::core::WaitFor; +use testcontainers::runners::SyncRunner; +use testcontainers::{GenericImage, RunnableImage}; + +// ============================================================================= +// Docker image constants +// ============================================================================= + +/// Qtum regtest docker image +pub const QTUM_REGTEST_DOCKER_IMAGE: &str = "docker.io/gleec/qtumregtest"; +/// Qtum regtest docker image with tag +pub const QTUM_REGTEST_DOCKER_IMAGE_WITH_TAG: &str = "docker.io/gleec/qtumregtest:latest"; + +// ============================================================================= +// Global state (OnceLock for contract addresses) +// ============================================================================= + +/// QICK token contract address +static QICK_TOKEN_ADDRESS: OnceLock = OnceLock::new(); +/// QORTY token contract address +static QORTY_TOKEN_ADDRESS: OnceLock = OnceLock::new(); +/// QRC20 swap contract address +static QRC20_SWAP_CONTRACT_ADDRESS: OnceLock = OnceLock::new(); +/// Path to Qtum config file +static QTUM_CONF_PATH: OnceLock = OnceLock::new(); + +/// Get the QICK token contract address. +/// Panics if called before initialization. +pub fn qick_token_address() -> H160Eth { + *QICK_TOKEN_ADDRESS + .get() + .expect("QICK_TOKEN_ADDRESS not initialized - ensure QRC20 init has run") +} + +/// Get the QORTY token contract address. +/// Panics if called before initialization. +pub fn qorty_token_address() -> H160Eth { + *QORTY_TOKEN_ADDRESS + .get() + .expect("QORTY_TOKEN_ADDRESS not initialized - ensure QRC20 init has run") +} + +/// Get the QRC20 swap contract address. +/// Panics if called before initialization. +pub fn qrc20_swap_contract_address() -> H160Eth { + *QRC20_SWAP_CONTRACT_ADDRESS + .get() + .expect("QRC20_SWAP_CONTRACT_ADDRESS not initialized - ensure QRC20 init has run") +} + +/// Get the Qtum config file path. +/// Panics if called before initialization. +pub fn qtum_conf_path() -> &'static PathBuf { + QTUM_CONF_PATH + .get() + .expect("QTUM_CONF_PATH not initialized - ensure QRC20 init has run") +} + +/// Set the QICK token contract address (for initialization). +pub fn set_qick_token_address(addr: H160Eth) { + QICK_TOKEN_ADDRESS + .set(addr) + .expect("QICK_TOKEN_ADDRESS already initialized"); +} + +/// Set the QORTY token contract address (for initialization). +pub fn set_qorty_token_address(addr: H160Eth) { + QORTY_TOKEN_ADDRESS + .set(addr) + .expect("QORTY_TOKEN_ADDRESS already initialized"); +} + +/// Set the QRC20 swap contract address (for initialization). +pub fn set_qrc20_swap_contract_address(addr: H160Eth) { + QRC20_SWAP_CONTRACT_ADDRESS + .set(addr) + .expect("QRC20_SWAP_CONTRACT_ADDRESS already initialized"); +} + +/// Set the Qtum config file path (for initialization). +pub fn set_qtum_conf_path(path: PathBuf) { + QTUM_CONF_PATH.set(path).expect("QTUM_CONF_PATH already initialized"); +} + +/// Setup Qtum configuration from a docker-compose container. +/// +/// Copies the Qtum configuration file from the compose container to the local +/// daemon data directory. Used when tests run against pre-started compose nodes. +pub fn setup_qtum_conf_for_compose() { + use coins::utxo::coin_daemon_data_dir; + + let mut conf_path = coin_daemon_data_dir("qtum", false); + std::fs::create_dir_all(&conf_path).unwrap(); + conf_path.push("qtum.conf"); + + let container_id = resolve_compose_container_id(KDF_QTUM_SERVICE); + docker_cp_from_container(&container_id, "/data/node_0/qtum.conf", &conf_path); + wait_for_file(&conf_path, 3000); + + set_qtum_conf_path(conf_path); +} + +// ============================================================================= +// Constants +// ============================================================================= + +/// Qtum address label used in tests +pub const QTUM_ADDRESS_LABEL: &str = "MM2_ADDRESS_LABEL"; + +// ============================================================================= +// Utility functions +// ============================================================================= + +/// Get only one address assigned the specified label. +pub fn get_address_by_label(coin: T, label: &str) -> String +where + T: AsRef, +{ + let native = match coin.as_ref().rpc_client { + UtxoRpcClientEnum::Native(ref native) => native, + UtxoRpcClientEnum::Electrum(_) => panic!("NativeClient expected"), + }; + let mut addresses = block_on_f01(native.get_addresses_by_label(label)) + .expect("!getaddressesbylabel") + .into_iter(); + match addresses.next() { + Some((addr, _purpose)) if addresses.next().is_none() => addr, + Some(_) => panic!("Expected only one address by {:?}", label), + None => panic!("Expected one address by {:?}", label), + } +} + +/// Build `Qrc20Coin` from ticker and privkey without filling the balance. +pub fn qrc20_coin_from_privkey(ticker: &str, priv_key: Secp256k1Secret) -> (MmArc, Qrc20Coin) { + use crate::docker_tests::helpers::utxo::import_address; + + let contract_address = match ticker { + "QICK" => qick_token_address(), + "QORTY" => qorty_token_address(), + _ => panic!("Expected QICK or QORTY ticker"), + }; + let swap_contract_address = qrc20_swap_contract_address(); + let platform = "QTUM"; + let ctx = MmCtxBuilder::new().into_mm_arc(); + let confpath = qtum_conf_path(); + let conf = json!({ + "coin":ticker, + "decimals": 8, + "required_confirmations":0, + "pubtype":120, + "p2shtype":110, + "wiftype":128, + "mm2":1, + "mature_confirmations":500, + "network":"regtest", + "confpath": confpath, + "dust": 72800, + }); + let req = json!({ + "method": "enable", + "swap_contract_address": format!("{:#02x}", swap_contract_address), + }); + let params = Qrc20ActivationParams::from_legacy_req(&req).unwrap(); + + let coin = block_on(qrc20_coin_with_priv_key( + &ctx, + ticker, + platform, + &conf, + ¶ms, + priv_key, + contract_address, + )) + .unwrap(); + + block_on(import_address(&coin)); + (ctx, coin) +} + +/// Get the QRC20 coin config item for MM2 config. +pub fn qrc20_coin_conf_item(ticker: &str) -> Json { + let contract_address = match ticker { + "QICK" => qick_token_address(), + "QORTY" => qorty_token_address(), + _ => panic!("Expected either QICK or QORTY ticker, found {}", ticker), + }; + let contract_address = format!("{contract_address:#02x}"); + + let confpath = qtum_conf_path(); + json!({ + "coin":ticker, + "required_confirmations":1, + "pubtype":120, + "p2shtype":110, + "wiftype":128, + "mature_confirmations":500, + "confpath":confpath, + "network":"regtest", + "protocol":{"type":"QRC20","protocol_data":{"platform":"QTUM","contract_address":contract_address}}}) +} + +/// Fill a QRC20 address with tokens. +pub fn fill_qrc20_address(coin: &Qrc20Coin, amount: BigDecimal, timeout: u64) { + // prevent concurrent fill since daemon RPC returns errors if send_to_address + // is called concurrently (insufficient funds) and it also may return other errors + // if previous transaction is not confirmed yet + let _lock = block_on(QTUM_LOCK.lock()); + let timeout = wait_until_sec(timeout); + let client = match coin.as_ref().rpc_client { + UtxoRpcClientEnum::Native(ref client) => client, + UtxoRpcClientEnum::Electrum(_) => panic!("Expected NativeClient"), + }; + + use futures::TryFutureExt; + let from_addr = get_address_by_label(coin, QTUM_ADDRESS_LABEL); + let to_addr = block_on_f01(coin.my_addr_as_contract_addr().compat()).unwrap(); + let satoshis = sat_from_big_decimal(&amount, coin.as_ref().decimals).expect("!sat_from_big_decimal"); + + let hash = block_on_f01(client.transfer_tokens( + &coin.contract_address, + &from_addr, + to_addr, + satoshis.into(), + coin.as_ref().decimals, + )) + .expect("!transfer_tokens") + .txid; + + let tx_bytes = block_on_f01(client.get_transaction_bytes(&hash)).unwrap(); + log!("{:02x}", tx_bytes); + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: tx_bytes.0, + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); +} + +/// Generate random privkey, create a QRC20 coin and fill its address with the specified balance. +pub fn generate_qrc20_coin_with_random_privkey( + ticker: &str, + qtum_balance: BigDecimal, + qrc20_balance: BigDecimal, +) -> (MmArc, Qrc20Coin, Secp256k1Secret) { + let priv_key = random_secp256k1_secret(); + let (ctx, coin) = qrc20_coin_from_privkey(ticker, priv_key); + + let timeout = 30; // timeout if test takes more than 30 seconds to run + let my_address = coin.my_address().expect("!my_address"); + fill_address(&coin, &my_address, qtum_balance, timeout); + fill_qrc20_address(&coin, qrc20_balance, timeout); + (ctx, coin, priv_key) +} + +/// Generate a Qtum coin with random privkey. +pub fn generate_qtum_coin_with_random_privkey( + ticker: &str, + balance: BigDecimal, + txfee: Option, +) -> (MmArc, QtumCoin, [u8; 32]) { + let confpath = qtum_conf_path(); + let conf = json!({ + "coin":ticker, + "decimals":8, + "required_confirmations":0, + "pubtype":120, + "p2shtype": 110, + "wiftype":128, + "txfee": txfee, + "txfee_volatility_percent":0.1, + "mm2":1, + "mature_confirmations":500, + "network":"regtest", + "confpath": confpath, + "dust": 72800, + }); + let req = json!({"method": "enable"}); + let priv_key = random_secp256k1_secret(); + let ctx = MmCtxBuilder::new().into_mm_arc(); + let params = UtxoActivationParams::from_legacy_req(&req).unwrap(); + let coin = block_on(qtum_coin_with_priv_key(&ctx, ticker, &conf, ¶ms, priv_key)).unwrap(); + + let timeout = 30; // timeout if test takes more than 30 seconds to run + let my_address = coin.my_address().expect("!my_address"); + fill_address(&coin, &my_address, balance, timeout); + (ctx, coin, priv_key.take()) +} + +/// Generate a SegWit Qtum coin with random privkey. +pub fn generate_segwit_qtum_coin_with_random_privkey( + ticker: &str, + balance: BigDecimal, + txfee: Option, +) -> (MmArc, QtumCoin, Secp256k1Secret) { + let confpath = qtum_conf_path(); + let conf = json!({ + "coin":ticker, + "decimals":8, + "required_confirmations":0, + "pubtype":120, + "p2shtype": 110, + "wiftype":128, + "segwit":true, + "txfee": txfee, + "txfee_volatility_percent":0.1, + "mm2":1, + "mature_confirmations":500, + "network":"regtest", + "confpath": confpath, + "dust": 72800, + "bech32_hrp":"qcrt", + "address_format": { + "format": "segwit", + }, + }); + let req = json!({"method": "enable"}); + let priv_key = random_secp256k1_secret(); + let ctx = MmCtxBuilder::new().into_mm_arc(); + let params = UtxoActivationParams::from_legacy_req(&req).unwrap(); + let coin = block_on(qtum_coin_with_priv_key(&ctx, ticker, &conf, ¶ms, priv_key)).unwrap(); + + let timeout = 30; // timeout if test takes more than 30 seconds to run + let my_address = coin.my_address().expect("!my_address"); + fill_address(&coin, &my_address, balance, timeout); + (ctx, coin, priv_key) +} + +/// Wait for the `estimatesmartfee` returns no errors. +pub fn wait_for_estimate_smart_fee(timeout: u64) -> Result<(), String> { + enum EstimateSmartFeeState { + Idle, + Ok, + NotAvailable, + } + lazy_static! { + static ref LOCK: Mutex = Mutex::new(EstimateSmartFeeState::Idle); + } + + let state = &mut *LOCK.lock().unwrap(); + match state { + EstimateSmartFeeState::Ok => return Ok(()), + EstimateSmartFeeState::NotAvailable => return ERR!("estimatesmartfee not available"), + EstimateSmartFeeState::Idle => log!("Start wait_for_estimate_smart_fee"), + } + + let priv_key = random_secp256k1_secret(); + let (_ctx, coin) = qrc20_coin_from_privkey("QICK", priv_key); + let timeout = wait_until_sec(timeout); + let client = match coin.as_ref().rpc_client { + UtxoRpcClientEnum::Native(ref client) => client, + UtxoRpcClientEnum::Electrum(_) => panic!("Expected NativeClient"), + }; + while now_sec() < timeout { + if let Ok(res) = block_on_f01(client.estimate_smart_fee(&None, 1)) { + if res.errors.is_empty() { + *state = EstimateSmartFeeState::Ok; + return Ok(()); + } + } + thread::sleep(Duration::from_secs(1)); + } + + *state = EstimateSmartFeeState::NotAvailable; + ERR!("Waited too long for estimate_smart_fee to work") +} + +/// Enable QRC20 coin in MarketMaker. +pub async fn enable_qrc20_native(mm: &MarketMakerIt, coin: &str) -> Json { + let swap_contract_address = qrc20_swap_contract_address(); + + let native = mm + .rpc(&json! ({ + "userpass": mm.userpass, + "method": "enable", + "coin": coin, + "swap_contract_address": format!("{:#02x}", swap_contract_address), + "mm2": 1, + })) + .await + .unwrap(); + assert_eq!(native.0, StatusCode::OK, "'enable' failed: {}", native.1); + json::from_str(&native.1).unwrap() +} + +// ============================================================================= +// Docker node setup +// ============================================================================= + +// ============================================================================= +// QtumDockerOps - Docker ops for Qtum initialization +// ============================================================================= + +use crate::docker_tests::helpers::docker_ops::CoinDockerOps; + +const QRC20_TOKEN_BYTES: &str = "6080604052600860ff16600a0a633b9aca000260005534801561002157600080fd5b50600054600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002081905550610c69806100776000396000f3006080604052600436106100a4576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806306fdde03146100a9578063095ea7b31461013957806318160ddd1461019e57806323b872dd146101c9578063313ce5671461024e5780635a3b7e421461027f57806370a082311461030f57806395d89b4114610366578063a9059cbb146103f6578063dd62ed3e1461045b575b600080fd5b3480156100b557600080fd5b506100be6104d2565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156100fe5780820151818401526020810190506100e3565b50505050905090810190601f16801561012b5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561014557600080fd5b50610184600480360381019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291908035906020019092919050505061050b565b604051808215151515815260200191505060405180910390f35b3480156101aa57600080fd5b506101b36106bb565b6040518082815260200191505060405180910390f35b3480156101d557600080fd5b50610234600480360381019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506106c1565b604051808215151515815260200191505060405180910390f35b34801561025a57600080fd5b506102636109a1565b604051808260ff1660ff16815260200191505060405180910390f35b34801561028b57600080fd5b506102946109a6565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156102d45780820151818401526020810190506102b9565b50505050905090810190601f1680156103015780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561031b57600080fd5b50610350600480360381019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506109df565b6040518082815260200191505060405180910390f35b34801561037257600080fd5b5061037b6109f7565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156103bb5780820151818401526020810190506103a0565b50505050905090810190601f1680156103e85780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561040257600080fd5b50610441600480360381019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050610a30565b604051808215151515815260200191505060405180910390f35b34801561046757600080fd5b506104bc600480360381019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050610be1565b6040518082815260200191505060405180910390f35b6040805190810160405280600881526020017f515243205445535400000000000000000000000000000000000000000000000081525081565b60008260008173ffffffffffffffffffffffffffffffffffffffff161415151561053457600080fd5b60008314806105bf57506000600260003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054145b15156105ca57600080fd5b82600260003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055508373ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925856040518082815260200191505060405180910390a3600191505092915050565b60005481565b60008360008173ffffffffffffffffffffffffffffffffffffffff16141515156106ea57600080fd5b8360008173ffffffffffffffffffffffffffffffffffffffff161415151561071157600080fd5b610797600260008873ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205485610c06565b600260008873ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002081905550610860600160008873ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205485610c06565b600160008873ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055506108ec600160008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205485610c1f565b600160008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055508473ffffffffffffffffffffffffffffffffffffffff168673ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef866040518082815260200191505060405180910390a36001925050509392505050565b600881565b6040805190810160405280600981526020017f546f6b656e20302e31000000000000000000000000000000000000000000000081525081565b60016020528060005260406000206000915090505481565b6040805190810160405280600381526020017f515443000000000000000000000000000000000000000000000000000000000081525081565b60008260008173ffffffffffffffffffffffffffffffffffffffff1614151515610a5957600080fd5b610aa2600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205484610c06565b600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002081905550610b2e600160008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205484610c1f565b600160008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055508373ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef856040518082815260200191505060405180910390a3600191505092915050565b6002602052816000526040600020602052806000526040600020600091509150505481565b6000818310151515610c1457fe5b818303905092915050565b6000808284019050838110151515610c3357fe5b80915050929150505600a165627a7a723058207f2e5248b61b80365ea08a0f6d11ac0b47374c4dfd538de76bc2f19591bbbba40029"; +const QRC20_SWAP_CONTRACT_BYTES: &str = "608060405234801561001057600080fd5b50611437806100206000396000f3fe60806040526004361061004a5760003560e01c806302ed292b1461004f5780630716326d146100de578063152cf3af1461017b57806346fc0294146101f65780639b415b2a14610294575b600080fd5b34801561005b57600080fd5b506100dc600480360360a081101561007257600080fd5b81019080803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050610339565b005b3480156100ea57600080fd5b506101176004803603602081101561010157600080fd5b8101908080359060200190929190505050610867565b60405180846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526020018367ffffffffffffffff1667ffffffffffffffff16815260200182600381111561016557fe5b60ff168152602001935050505060405180910390f35b6101f46004803603608081101561019157600080fd5b8101908080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080356bffffffffffffffffffffffff19169060200190929190803567ffffffffffffffff1690602001909291905050506108bf565b005b34801561020257600080fd5b50610292600480360360a081101561021957600080fd5b81019080803590602001909291908035906020019092919080356bffffffffffffffffffffffff19169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050610bd9565b005b610337600480360360c08110156102aa57600080fd5b810190808035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080356bffffffffffffffffffffffff19169060200190929190803567ffffffffffffffff169060200190929190505050610fe2565b005b6001600381111561034657fe5b600080878152602001908152602001600020600001601c9054906101000a900460ff16600381111561037457fe5b1461037e57600080fd5b6000600333836003600288604051602001808281526020019150506040516020818303038152906040526040518082805190602001908083835b602083106103db57805182526020820191506020810190506020830392506103b8565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa15801561041d573d6000803e3d6000fd5b5050506040513d602081101561043257600080fd5b8101908080519060200190929190505050604051602001808281526020019150506040516020818303038152906040526040518082805190602001908083835b602083106104955780518252602082019150602081019050602083039250610472565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa1580156104d7573d6000803e3d6000fd5b5050506040515160601b8689604051602001808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b81526014018573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526014018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401828152602001955050505050506040516020818303038152906040526040518082805190602001908083835b602083106105fc57805182526020820191506020810190506020830392506105d9565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa15801561063e573d6000803e3d6000fd5b5050506040515160601b905060008087815260200190815260200160002060000160009054906101000a900460601b6bffffffffffffffffffffffff1916816bffffffffffffffffffffffff19161461069657600080fd5b6002600080888152602001908152602001600020600001601c6101000a81548160ff021916908360038111156106c857fe5b0217905550600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff16141561074e573373ffffffffffffffffffffffffffffffffffffffff166108fc869081150290604051600060405180830381858888f19350505050158015610748573d6000803e3d6000fd5b50610820565b60008390508073ffffffffffffffffffffffffffffffffffffffff1663a9059cbb33886040518363ffffffff1660e01b8152600401808373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200182815260200192505050602060405180830381600087803b1580156107da57600080fd5b505af11580156107ee573d6000803e3d6000fd5b505050506040513d602081101561080457600080fd5b810190808051906020019092919050505061081e57600080fd5b505b7f36c177bcb01c6d568244f05261e2946c8c977fa50822f3fa098c470770ee1f3e8685604051808381526020018281526020019250505060405180910390a1505050505050565b60006020528060005260406000206000915090508060000160009054906101000a900460601b908060000160149054906101000a900467ffffffffffffffff169080600001601c9054906101000a900460ff16905083565b600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff16141580156108fc5750600034115b801561094057506000600381111561091057fe5b600080868152602001908152602001600020600001601c9054906101000a900460ff16600381111561093e57fe5b145b61094957600080fd5b60006003843385600034604051602001808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b81526014018573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526014018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401828152602001955050505050506040516020818303038152906040526040518082805190602001908083835b60208310610a6c5780518252602082019150602081019050602083039250610a49565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa158015610aae573d6000803e3d6000fd5b5050506040515160601b90506040518060600160405280826bffffffffffffffffffffffff191681526020018367ffffffffffffffff16815260200160016003811115610af757fe5b81525060008087815260200190815260200160002060008201518160000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908360601c021790555060208201518160000160146101000a81548167ffffffffffffffff021916908367ffffffffffffffff160217905550604082015181600001601c6101000a81548160ff02191690836003811115610b9357fe5b02179055509050507fccc9c05183599bd3135da606eaaf535daffe256e9de33c048014cffcccd4ad57856040518082815260200191505060405180910390a15050505050565b60016003811115610be657fe5b600080878152602001908152602001600020600001601c9054906101000a900460ff166003811115610c1457fe5b14610c1e57600080fd5b600060038233868689604051602001808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b81526014018573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526014018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401828152602001955050505050506040516020818303038152906040526040518082805190602001908083835b60208310610d405780518252602082019150602081019050602083039250610d1d565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa158015610d82573d6000803e3d6000fd5b5050506040515160601b905060008087815260200190815260200160002060000160009054906101000a900460601b6bffffffffffffffffffffffff1916816bffffffffffffffffffffffff1916148015610e10575060008087815260200190815260200160002060000160149054906101000a900467ffffffffffffffff1667ffffffffffffffff164210155b610e1957600080fd5b6003600080888152602001908152602001600020600001601c6101000a81548160ff02191690836003811115610e4b57fe5b0217905550600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff161415610ed1573373ffffffffffffffffffffffffffffffffffffffff166108fc869081150290604051600060405180830381858888f19350505050158015610ecb573d6000803e3d6000fd5b50610fa3565b60008390508073ffffffffffffffffffffffffffffffffffffffff1663a9059cbb33886040518363ffffffff1660e01b8152600401808373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200182815260200192505050602060405180830381600087803b158015610f5d57600080fd5b505af1158015610f71573d6000803e3d6000fd5b505050506040513d6020811015610f8757600080fd5b8101908080519060200190929190505050610fa157600080fd5b505b7f1797d500133f8e427eb9da9523aa4a25cb40f50ebc7dbda3c7c81778973f35ba866040518082815260200191505060405180910390a1505050505050565b600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff161415801561101f5750600085115b801561106357506000600381111561103357fe5b600080888152602001908152602001600020600001601c9054906101000a900460ff16600381111561106157fe5b145b61106c57600080fd5b60006003843385888a604051602001808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b81526014018573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526014018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401828152602001955050505050506040516020818303038152906040526040518082805190602001908083835b6020831061118e578051825260208201915060208101905060208303925061116b565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa1580156111d0573d6000803e3d6000fd5b5050506040515160601b90506040518060600160405280826bffffffffffffffffffffffff191681526020018367ffffffffffffffff1681526020016001600381111561121957fe5b81525060008089815260200190815260200160002060008201518160000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908360601c021790555060208201518160000160146101000a81548167ffffffffffffffff021916908367ffffffffffffffff160217905550604082015181600001601c6101000a81548160ff021916908360038111156112b557fe5b021790555090505060008590508073ffffffffffffffffffffffffffffffffffffffff166323b872dd33308a6040518463ffffffff1660e01b8152600401808473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018281526020019350505050602060405180830381600087803b15801561137d57600080fd5b505af1158015611391573d6000803e3d6000fd5b505050506040513d60208110156113a757600080fd5b81019080805190602001909291905050506113c157600080fd5b7fccc9c05183599bd3135da606eaaf535daffe256e9de33c048014cffcccd4ad57886040518082815260200191505060405180910390a1505050505050505056fea265627a7a723158208c83db436905afce0b7be1012be64818c49323c12d451fe2ab6bce76ff6421c964736f6c63430005110032"; + +/// Docker ops for Qtum initialization. +/// Used to create contracts and configure the Qtum node. +pub struct QtumDockerOps { + #[allow(dead_code)] + ctx: MmArc, + coin: QtumCoin, +} + +impl CoinDockerOps for QtumDockerOps { + fn rpc_client(&self) -> &UtxoRpcClientEnum { + &self.coin.as_ref().rpc_client + } +} + +impl QtumDockerOps { + pub fn new() -> QtumDockerOps { + let ctx = MmCtxBuilder::new().into_mm_arc(); + let confpath = qtum_conf_path(); + let conf = json!({"coin":"QTUM","decimals":8,"network":"regtest","confpath":confpath}); + let req = json!({ + "method": "enable", + }); + let priv_key = Secp256k1Secret::from("809465b17d0a4ddb3e4c69e8f23c2cabad868f51f8bed5c765ad1d6516c3306f"); + let params = UtxoActivationParams::from_legacy_req(&req).unwrap(); + let coin = block_on(qtum_coin_with_priv_key(&ctx, "QTUM", &conf, ¶ms, priv_key)).unwrap(); + QtumDockerOps { ctx, coin } + } + + pub fn initialize_contracts(&self) { + let sender = get_address_by_label(&self.coin, QTUM_ADDRESS_LABEL); + set_qick_token_address(self.create_contract(&sender, QRC20_TOKEN_BYTES)); + set_qorty_token_address(self.create_contract(&sender, QRC20_TOKEN_BYTES)); + set_qrc20_swap_contract_address(self.create_contract(&sender, QRC20_SWAP_CONTRACT_BYTES)); + } + + fn create_contract(&self, sender: &str, hexbytes: &str) -> H160Eth { + let bytecode = hex::decode(hexbytes).expect("Hex encoded bytes expected"); + let gas_limit = 2_500_000u64; + let gas_price = BigDecimal::from_str("0.0000004").unwrap(); + + match self.coin.as_ref().rpc_client { + UtxoRpcClientEnum::Native(ref native) => { + let result = block_on_f01(native.create_contract(&bytecode.into(), gas_limit, gas_price, sender)) + .expect("!createcontract"); + result.address.0.into() + }, + UtxoRpcClientEnum::Electrum(_) => panic!("Native client expected"), + } + } +} + +// ============================================================================= +// Docker node setup +// ============================================================================= + +/// Start a Qtum regtest docker node and initialize configuration. +pub fn qtum_docker_node(port: u16) -> DockerNode { + let image = GenericImage::new(QTUM_REGTEST_DOCKER_IMAGE, "latest") + .with_env_var("CLIENTS", "2") + .with_env_var("COIN_RPC_PORT", port.to_string()) + .with_env_var("ADDRESS_LABEL", QTUM_ADDRESS_LABEL) + .with_env_var("FILL_MEMPOOL", "true") + .with_wait_for(WaitFor::message_on_stdout("config is ready")); + let image = RunnableImage::from(image).with_mapped_port((port, port)); + let container = image.start().expect("Failed to start Qtum regtest docker container"); + + let name = "qtum"; + let mut conf_path = temp_dir().join("qtum-regtest"); + std::fs::create_dir_all(&conf_path).unwrap(); + conf_path.push(format!("{name}.conf")); + Command::new("docker") + .arg("cp") + .arg(format!("{}:/data/node_0/{}.conf", container.id(), name)) + .arg(&conf_path) + .status() + .expect("Failed to execute docker command"); + let timeout = wait_until_ms(3000); + loop { + if conf_path.exists() { + break; + }; + assert!(now_ms() < timeout, "Test timed out"); + } + + set_qtum_conf_path(conf_path); + DockerNode { + container, + ticker: name.to_owned(), + port, + } +} diff --git a/mm2src/mm2_main/tests/docker_tests/helpers/sia.rs b/mm2src/mm2_main/tests/docker_tests/helpers/sia.rs new file mode 100644 index 0000000000..b4bfae4e10 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/helpers/sia.rs @@ -0,0 +1,68 @@ +//! Sia helpers for docker tests. +//! +//! This module provides: +//! - Sia docker node configuration and startup +//! - Sia RPC connection parameters + +use super::env::DockerNode; +use testcontainers::core::Mount; +use testcontainers::runners::SyncRunner; +use testcontainers::{core::WaitFor, GenericImage, RunnableImage}; + +// ============================================================================= +// Sia node configuration +// ============================================================================= + +/// SIA daemon RPC connection parameters: (host, port, password) +pub static SIA_RPC_PARAMS: (&str, u16, &str) = ("127.0.0.1", 9980, "password"); + +/// SIA docker image name +pub const SIA_DOCKER_IMAGE: &str = "ghcr.io/siafoundation/walletd"; +/// SIA docker image with tag +pub const SIA_DOCKER_IMAGE_WITH_TAG: &str = "ghcr.io/siafoundation/walletd:latest"; + +// ============================================================================= +// Docker node helpers +// ============================================================================= + +/// Start a Sia docker node for testing. +/// +/// This helper creates the necessary config files and starts the walletd container. +pub fn sia_docker_node(ticker: &'static str, port: u16) -> DockerNode { + use crate::sia_tests::utils::{WALLETD_CONFIG, WALLETD_NETWORK_CONFIG}; + + let config_dir = std::env::temp_dir() + .join(format!( + "sia-docker-tests-temp-{}", + chrono::Local::now().format("%Y-%m-%d_%H-%M-%S-%3f") + )) + .join("walletd_config"); + std::fs::create_dir_all(&config_dir).unwrap(); + + // Write walletd.yml + std::fs::write(config_dir.join("walletd.yml"), WALLETD_CONFIG).expect("failed to write walletd.yml"); + + // Write ci_network.json + std::fs::write(config_dir.join("ci_network.json"), WALLETD_NETWORK_CONFIG) + .expect("failed to write ci_network.json"); + + let image = GenericImage::new(SIA_DOCKER_IMAGE, "latest") + .with_env_var("WALLETD_CONFIG_FILE", "/config/walletd.yml") + .with_wait_for(WaitFor::message_on_stdout("node started")) + .with_mount(Mount::bind_mount( + config_dir.to_str().expect("config path is invalid"), + "/config", + )); + + let args = vec!["-network=/config/ci_network.json".to_string(), "-debug".to_string()]; + let image = RunnableImage::from(image) + .with_mapped_port((port, port)) + .with_args(args); + + let container = image.start().expect("Failed to start Sia docker node"); + DockerNode { + container, + ticker: ticker.into(), + port, + } +} diff --git a/mm2src/mm2_main/tests/docker_tests/helpers/slp.rs b/mm2src/mm2_main/tests/docker_tests/helpers/slp.rs new file mode 100644 index 0000000000..9b79d25092 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/helpers/slp.rs @@ -0,0 +1,310 @@ +//! BCH/SLP helpers for docker tests. +//! +//! This module was extracted from `helpers::utxo`. +//! It provides: +//! - `BchDockerOps` wrapper for the FORSLP node (BCH-like UTXO chain with SLP enabled) +//! - `forslp_docker_node()` to start the FORSLP docker container +//! - `initialize_slp()` to mint/distribute test SLP tokens +//! - Accessors to retrieve a prefilled SLP private key and the token id + +use super::docker_ops::CoinDockerOps; +use super::env::DockerNode; +use chain::TransactionOutput; +use coins::utxo::bch::{bch_coin_with_priv_key, BchActivationRequest, BchCoin}; +use coins::utxo::rpc_clients::{UtxoRpcClientEnum, UtxoRpcClientOps}; +use coins::utxo::slp::{slp_genesis_output, SlpOutput, SlpToken}; +use coins::utxo::utxo_common::send_outputs_from_my_address; +use coins::utxo::{coin_daemon_data_dir, zcash_params_path, UtxoCoinFields, UtxoCommonOps}; +use coins::Transaction; +use coins::{ConfirmPaymentInput, MarketCoinOps}; +use common::executor::Timer; +use common::Future01CompatExt; +use common::{block_on, block_on_f01, now_ms, now_sec, wait_until_ms, wait_until_sec}; +use crypto::Secp256k1Secret; +use keys::{AddressBuilder, KeyPair, NetworkPrefix as CashAddrPrefix}; +use mm2_core::mm_ctx::{MmArc, MmCtxBuilder}; +use mm2_number::BigDecimal; +use primitives::hash::H256; +use script::Builder; +use serde_json::json; +use std::convert::TryFrom; +use std::process::Command; +use std::sync::Mutex; +use testcontainers::core::Mount; +use testcontainers::runners::SyncRunner; +use testcontainers::GenericImage; +use testcontainers::{core::WaitFor, RunnableImage}; +use tokio::sync::Mutex as AsyncMutex; + +// ============================================================================= +// SLP token metadata +// ============================================================================= + +lazy_static! { + /// SLP token ID (genesis tx hash). + pub static ref SLP_TOKEN_ID: Mutex = Mutex::new(H256::default()); + + /// Private keys supplied with 1000 SLP tokens on tests initialization. + /// + /// Due to the SLP protocol limitations only 19 outputs (18 + change) can be sent in one transaction. + pub static ref SLP_TOKEN_OWNERS: Mutex> = Mutex::new(Vec::with_capacity(18)); + + /// Lock for FORSLP funding operations. + static ref FORSLP_LOCK: AsyncMutex<()> = AsyncMutex::new(()); +} + +// ============================================================================= +// Docker image constants +// ============================================================================= + +/// FORSLP docker image (same as UTXO testblockchain). +const FORSLP_DOCKER_IMAGE: &str = "docker.io/gleec/testblockchain"; + +/// FORSLP docker image with tag (used by runner::required_images). +pub const FORSLP_IMAGE_WITH_TAG: &str = "docker.io/gleec/testblockchain:multiarch"; + +// ============================================================================= +// Docker node helpers +// ============================================================================= + +/// Start the FORSLP dockerized BCH/SLP node. +pub fn forslp_docker_node(port: u16) -> DockerNode { + let ticker = "FORSLP"; + let image = GenericImage::new(FORSLP_DOCKER_IMAGE, "multiarch") + .with_mount(Mount::bind_mount( + zcash_params_path().display().to_string(), + "/root/.zcash-params", + )) + .with_env_var("CLIENTS", "2") + .with_env_var("CHAIN", ticker) + .with_env_var("TEST_ADDY", "R9imXLs1hEcU9KbFDQq2hJEEJ1P5UoekaF") + .with_env_var("TEST_WIF", "UqqW7f766rADem9heD8vSBvvrdfJb3zg5r8du9rJxPtccjWf7RG9") + .with_env_var( + "TEST_PUBKEY", + "021607076d7a2cb148d542fb9644c04ffc22d2cca752f80755a0402a24c567b17a", + ) + .with_env_var("DAEMON_URL", "http://test:test@127.0.0.1:7000") + .with_env_var("COIN", "Komodo") + .with_env_var("COIN_RPC_PORT", port.to_string()) + .with_wait_for(WaitFor::message_on_stdout("config is ready")); + let image = RunnableImage::from(image).with_mapped_port((port, port)); + let container = image.start().expect("Failed to start FORSLP docker node"); + + let mut conf_path = coin_daemon_data_dir(ticker, true); + std::fs::create_dir_all(&conf_path).unwrap(); + conf_path.push(format!("{ticker}.conf")); + + Command::new("docker") + .arg("cp") + .arg(format!("{}:/data/node_0/{}.conf", container.id(), ticker)) + .arg(&conf_path) + .status() + .expect("Failed to execute docker command"); + + let timeout = wait_until_ms(3000); + loop { + if conf_path.exists() { + break; + }; + assert!(now_ms() < timeout, "Test timed out waiting for config"); + } + + DockerNode { + container, + ticker: ticker.into(), + port, + } +} + +// ============================================================================= +// BCH/SLP funding utilities +// ============================================================================= + +/// Fill a BCH/SLP address with the specified amount. +fn fill_bch_address(coin: &T, address: &str, amount: BigDecimal, timeout: u64) +where + T: MarketCoinOps + AsRef, +{ + block_on(fill_bch_address_async(coin, address, amount, timeout)); +} + +/// Fill a BCH/SLP address with the specified amount (async version). +async fn fill_bch_address_async(coin: &T, address: &str, amount: BigDecimal, timeout: u64) +where + T: MarketCoinOps + AsRef, +{ + let _lock = FORSLP_LOCK.lock().await; + let timeout = wait_until_sec(timeout); + + if let UtxoRpcClientEnum::Native(client) = &coin.as_ref().rpc_client { + client.import_address(address, address, false).compat().await.unwrap(); + let hash = client.send_to_address(address, &amount).compat().await.unwrap(); + let tx_bytes = client.get_transaction_bytes(&hash).compat().await.unwrap(); + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: tx_bytes.clone().0, + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + coin.wait_for_confirmations(confirm_payment_input) + .compat() + .await + .unwrap(); + log!("{:02x}", tx_bytes); + loop { + let unspents = client + .list_unspent_impl(0, i32::MAX, vec![address.to_string()]) + .compat() + .await + .unwrap(); + if !unspents.is_empty() { + break; + } + assert!(now_sec() < timeout, "Test timed out"); + Timer::sleep(1.0).await; + } + }; +} + +// ============================================================================= +// BCH/SLP docker ops +// ============================================================================= + +/// Docker operations for BCH/SLP coins (FORSLP). +pub struct BchDockerOps { + #[allow(dead_code)] + ctx: MmArc, + coin: BchCoin, +} + +impl BchDockerOps { + /// Create BchDockerOps from ticker. + pub fn from_ticker(ticker: &str) -> BchDockerOps { + let conf = + json!({"coin": ticker,"asset": ticker,"txfee":1000,"network": "regtest","txversion":4,"overwintered":1}); + let req = json!({"method":"enable", "bchd_urls": [], "allow_slp_unsafe_conf": true}); + let priv_key = Secp256k1Secret::from("809465b17d0a4ddb3e4c69e8f23c2cabad868f51f8bed5c765ad1d6516c3306f"); + let ctx = MmCtxBuilder::new().into_mm_arc(); + let params = BchActivationRequest::from_legacy_req(&req).unwrap(); + + let coin = block_on(bch_coin_with_priv_key( + &ctx, + ticker, + &conf, + params, + CashAddrPrefix::SlpTest, + priv_key, + )) + .unwrap(); + BchDockerOps { ctx, coin } + } + + /// Initialize SLP tokens: + /// - Fund node wallet + /// - Create SLP genesis + /// - Distribute tokens to 18 new addresses + /// - Store their privkeys into `SLP_TOKEN_OWNERS` and token id into `SLP_TOKEN_ID` + pub fn initialize_slp(&self) { + fill_bch_address(&self.coin, &self.coin.my_address().unwrap(), 100000.into(), 30); + let mut slp_privkeys = vec![]; + + let slp_genesis_op_ret = slp_genesis_output("ADEXSLP", "ADEXSLP", None, None, 8, None, 1000000_00000000); + let slp_genesis = TransactionOutput { + value: self.coin.as_ref().dust_amount, + script_pubkey: Builder::build_p2pkh(&self.coin.my_public_key().unwrap().address_hash().into()).to_bytes(), + }; + + let mut bch_outputs = vec![slp_genesis_op_ret, slp_genesis]; + let mut slp_outputs = vec![]; + + for _ in 0..18 { + let key_pair = KeyPair::random_compressed(); + let address = AddressBuilder::new( + Default::default(), + Default::default(), + self.coin.as_ref().conf.address_prefixes.clone(), + None, + ) + .as_pkh_from_pk(*key_pair.public()) + .build() + .expect("valid address props"); + + block_on_f01( + self.native_client() + .import_address(&address.to_string(), &address.to_string(), false), + ) + .unwrap(); + + let script_pubkey = Builder::build_p2pkh(&key_pair.public().address_hash().into()); + + bch_outputs.push(TransactionOutput { + value: 1000_00000000, + script_pubkey: script_pubkey.to_bytes(), + }); + + slp_outputs.push(SlpOutput { + amount: 1000_00000000, + script_pubkey: script_pubkey.to_bytes(), + }); + slp_privkeys.push(*key_pair.private_ref()); + } + + let slp_genesis_tx = block_on_f01(send_outputs_from_my_address(self.coin.clone(), bch_outputs)).unwrap(); + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: slp_genesis_tx.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: wait_until_sec(30), + check_every: 1, + }; + block_on_f01(self.coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + + let adex_slp = SlpToken::new( + 8, + "ADEXSLP".into(), + <&[u8; 32]>::try_from(slp_genesis_tx.tx_hash_as_bytes().as_slice()) + .unwrap() + .into(), + self.coin.clone(), + 1, + ) + .unwrap(); + + let tx = block_on(adex_slp.send_slp_outputs(slp_outputs)).unwrap(); + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: tx.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: wait_until_sec(30), + check_every: 1, + }; + block_on_f01(self.coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + *SLP_TOKEN_OWNERS.lock().unwrap() = slp_privkeys; + *SLP_TOKEN_ID.lock().unwrap() = <[u8; 32]>::try_from(slp_genesis_tx.tx_hash_as_bytes().as_slice()) + .unwrap() + .into(); + } +} + +impl CoinDockerOps for BchDockerOps { + fn rpc_client(&self) -> &UtxoRpcClientEnum { + &self.coin.as_ref().rpc_client + } +} + +// ============================================================================= +// Public accessors used by tests +// ============================================================================= + +/// Get a prefilled SLP privkey from the pool. +/// +/// Panics if initialization didn't happen (runner must call `setup_slp()`). +pub fn get_prefilled_slp_privkey() -> [u8; 32] { + SLP_TOKEN_OWNERS.lock().unwrap().remove(0) +} + +/// Get the SLP token ID as hex string. +pub fn get_slp_token_id() -> String { + hex::encode(SLP_TOKEN_ID.lock().unwrap().as_slice()) +} diff --git a/mm2src/mm2_main/tests/docker_tests/helpers/swap.rs b/mm2src/mm2_main/tests/docker_tests/helpers/swap.rs new file mode 100644 index 0000000000..1c6413891b --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/helpers/swap.rs @@ -0,0 +1,480 @@ +//! Swap orchestration helpers for docker tests. +//! +//! This module provides high-level cross-chain atomic swap test scenarios. +//! For chain-specific helpers, import directly from the other `helpers` submodules. +//! +//! ## Feature gating +//! +//! This module is compiled for all docker tests (gated by `run-docker-tests`), but +//! chain-specific imports and code blocks are gated by their respective feature flags: +//! +//! - ETH: `docker-tests-eth`, `docker-tests-ordermatch` +//! - QRC20: `docker-tests-qrc20` +//! - UTXO: `docker-tests-swaps`, `docker-tests-ordermatch`, `docker-tests-watchers`, `docker-tests-qrc20`, `docker-tests-sia`, `docker-tests-slp` +//! - SLP: `docker-tests-slp` + +use common::block_on; +use crypto::privkey::key_pair_from_secret; +use mm2_test_helpers::for_tests::{ + check_my_swap_status, check_recent_swaps, mm_dump, wait_check_stats_swap_status, MarketMakerIt, +}; +use serde_json::{json, Value as Json}; +use std::thread; +use std::time::Duration; + +use crypto::Secp256k1Secret; + +// random_secp256k1_secret - used by non-SLP swap paths +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-qrc20", + feature = "docker-tests-eth", + feature = "docker-tests-sia" +))] +use super::env::random_secp256k1_secret; + +// SET_BURN_PUBKEY_TO_ALICE - used by trade_base_rel +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-qrc20", + feature = "docker-tests-slp", + feature = "docker-tests-eth", + feature = "docker-tests-integration" +))] +use super::env::SET_BURN_PUBKEY_TO_ALICE; + +/// Timeout in seconds for wallet funding operations during test setup. +#[cfg(any( + feature = "docker-tests-qrc20", + feature = "docker-tests-swaps", + feature = "docker-tests-sia" +))] +const WALLET_FUNDING_TIMEOUT_SEC: u64 = 30; + +// ETH imports +#[cfg(feature = "docker-tests-eth")] +use super::eth::{erc20_contract_checksum, fill_eth_erc20_with_private_key, swap_contract_checksum, GETH_RPC_URL}; +#[cfg(feature = "docker-tests-eth")] +use mm2_test_helpers::for_tests::{enable_eth_coin, erc20_dev_conf, eth_dev_conf}; + +// QRC20 imports +#[cfg(feature = "docker-tests-qrc20")] +use super::qrc20::{ + enable_qrc20_native, fill_qrc20_address, generate_segwit_qtum_coin_with_random_privkey, qrc20_coin_conf_item, + qrc20_coin_from_privkey, qtum_conf_path, wait_for_estimate_smart_fee, +}; +#[cfg(feature = "docker-tests-qrc20")] +use super::utxo::fill_address as fill_utxo_address_qrc20; +#[cfg(feature = "docker-tests-qrc20")] +use coins::MarketCoinOps; +#[cfg(feature = "docker-tests-qrc20")] +use mm2_test_helpers::for_tests::enable_native as enable_native_qrc20; + +// UTXO imports (non-QRC20 paths) +#[cfg(all( + any(feature = "docker-tests-swaps", feature = "docker-tests-sia"), + not(feature = "docker-tests-qrc20") +))] +use super::utxo::{fill_address, utxo_coin_from_privkey}; +#[cfg(all( + any(feature = "docker-tests-swaps", feature = "docker-tests-sia"), + not(feature = "docker-tests-qrc20") +))] +use coins::MarketCoinOps; +#[cfg(all( + any(feature = "docker-tests-swaps", feature = "docker-tests-sia"), + not(feature = "docker-tests-qrc20") +))] +use mm2_test_helpers::for_tests::enable_native; + +// UTXO imports (QRC20 path - already imports fill_address as fill_utxo_address_qrc20) +#[cfg(feature = "docker-tests-qrc20")] +use super::utxo::utxo_coin_from_privkey as utxo_coin_from_privkey_qrc20; + +// SLP imports +#[cfg(feature = "docker-tests-slp")] +use super::slp::{get_prefilled_slp_privkey, get_slp_token_id}; +#[cfg(feature = "docker-tests-slp")] +use mm2_test_helpers::for_tests::{enable_native as enable_native_slp, enable_native_bch}; + +// ============================================================================= +// Cross-chain swap test scenarios +// ============================================================================= + +/// End-to-end atomic swap test between two coins. +/// +/// This function: +/// 1. Generates and funds wallets for both maker (base) and taker (rel) coins +/// 2. Starts two MarketMaker instances (Bob as maker, Alice as taker) +/// 3. Enables all required coins on both instances +/// 4. Places a setprice order and matches with a buy order +/// 5. Waits for swap completion and verifies both sides +/// +/// ## Feature requirements +/// +/// Different coin pairs require different feature flags: +/// - ETH/ERC20DEV: `docker-tests-eth` or `docker-tests-ordermatch` +/// - QTUM/QICK/QORTY: `docker-tests-qrc20` +/// - MYCOIN/MYCOIN1: `docker-tests-swaps`, `docker-tests-ordermatch`, `docker-tests-watchers`, `docker-tests-qrc20`, `docker-tests-sia` +/// - FORSLP/ADEXSLP: `docker-tests-slp` +pub fn trade_base_rel((base, rel): (&str, &str)) { + /// Generate a wallet with the random private key and fill the wallet with funds. + fn generate_and_fill_priv_key(ticker: &str) -> Secp256k1Secret { + match ticker { + #[cfg(feature = "docker-tests-qrc20")] + "QTUM" => { + wait_for_estimate_smart_fee(WALLET_FUNDING_TIMEOUT_SEC).expect("!wait_for_estimate_smart_fee"); + let (_ctx, _coin, priv_key) = generate_segwit_qtum_coin_with_random_privkey("QTUM", 10.into(), Some(0)); + priv_key + }, + #[cfg(feature = "docker-tests-qrc20")] + "QICK" | "QORTY" => { + let priv_key = random_secp256k1_secret(); + let (_ctx, coin) = qrc20_coin_from_privkey(ticker, priv_key); + let my_address = coin.my_address().expect("!my_address"); + fill_utxo_address_qrc20(&coin, &my_address, 10.into(), WALLET_FUNDING_TIMEOUT_SEC); + fill_qrc20_address(&coin, 10.into(), WALLET_FUNDING_TIMEOUT_SEC); + priv_key + }, + #[cfg(feature = "docker-tests-qrc20")] + "MYCOIN" | "MYCOIN1" => { + let priv_key = random_secp256k1_secret(); + let (_ctx, coin) = utxo_coin_from_privkey_qrc20(ticker, priv_key); + let my_address = coin.my_address().expect("!my_address"); + fill_utxo_address_qrc20(&coin, &my_address, 10.into(), WALLET_FUNDING_TIMEOUT_SEC); + priv_key + }, + #[cfg(all( + any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-sia" + ), + not(feature = "docker-tests-qrc20") + ))] + "MYCOIN" | "MYCOIN1" => { + let priv_key = random_secp256k1_secret(); + let (_ctx, coin) = utxo_coin_from_privkey(ticker, priv_key); + let my_address = coin.my_address().expect("!my_address"); + fill_address(&coin, &my_address, 10.into(), WALLET_FUNDING_TIMEOUT_SEC); + priv_key + }, + #[cfg(feature = "docker-tests-slp")] + "ADEXSLP" | "FORSLP" => Secp256k1Secret::from(get_prefilled_slp_privkey()), + #[cfg(feature = "docker-tests-eth")] + "ETH" | "ERC20DEV" => { + let priv_key = random_secp256k1_secret(); + fill_eth_erc20_with_private_key(priv_key); + priv_key + }, + _ => panic!( + "Unsupported ticker: {}. Check that the required feature flag is enabled. \ + ETH/ERC20DEV: docker-tests-eth or docker-tests-ordermatch. \ + QTUM/QICK/QORTY/MYCOIN: docker-tests-qrc20. \ + MYCOIN/MYCOIN1: docker-tests-swaps, docker-tests-ordermatch, docker-tests-watchers, docker-tests-sia. \ + FORSLP/ADEXSLP: docker-tests-slp.", + ticker + ), + } + } + + let bob_priv_key = generate_and_fill_priv_key(base); + let alice_priv_key = generate_and_fill_priv_key(rel); + let alice_pubkey_str = hex::encode( + key_pair_from_secret(&alice_priv_key) + .expect("valid test key pair") + .public() + .to_vec(), + ); + + let mut envs = vec![]; + if SET_BURN_PUBKEY_TO_ALICE.get() { + envs.push(("TEST_BURN_ADDR_RAW_PUBKEY", alice_pubkey_str.as_str())); + } + + // Build coins config based on enabled features + let mut coins_vec: Vec = Vec::new(); + + #[cfg(feature = "docker-tests-eth")] + { + coins_vec.push(eth_dev_conf()); + coins_vec.push(erc20_dev_conf(&erc20_contract_checksum())); + } + + #[cfg(feature = "docker-tests-qrc20")] + { + let confpath = qtum_conf_path(); + coins_vec.push(qrc20_coin_conf_item("QICK")); + coins_vec.push(qrc20_coin_conf_item("QORTY")); + coins_vec.push(json!({ + "coin": "QTUM", "asset": "QTUM", "required_confirmations": 0, "decimals": 8, + "pubtype": 120, "p2shtype": 110, "wiftype": 128, "segwit": true, "txfee": 0, + "txfee_volatility_percent": 0.1, "dust": 72800, "mm2": 1, "network": "regtest", + "confpath": confpath, "protocol": {"type": "UTXO"}, "bech32_hrp": "qcrt", + "address_format": {"format": "segwit"} + })); + coins_vec.push(json!({ + "coin": "MYCOIN", "asset": "MYCOIN", "required_confirmations": 0, + "txversion": 4, "overwintered": 1, "txfee": 1000, "protocol": {"type": "UTXO"} + })); + coins_vec.push(json!({ + "coin": "MYCOIN1", "asset": "MYCOIN1", "required_confirmations": 0, + "txversion": 4, "overwintered": 1, "txfee": 1000, "protocol": {"type": "UTXO"} + })); + } + + #[cfg(all( + any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-sia" + ), + not(feature = "docker-tests-qrc20") + ))] + { + coins_vec.push(json!({ + "coin": "MYCOIN", "asset": "MYCOIN", "required_confirmations": 0, + "txversion": 4, "overwintered": 1, "txfee": 1000, "protocol": {"type": "UTXO"} + })); + coins_vec.push(json!({ + "coin": "MYCOIN1", "asset": "MYCOIN1", "required_confirmations": 0, + "txversion": 4, "overwintered": 1, "txfee": 1000, "protocol": {"type": "UTXO"} + })); + } + + #[cfg(feature = "docker-tests-slp")] + { + coins_vec.push(json!({ + "coin": "FORSLP", "asset": "FORSLP", "required_confirmations": 0, + "txversion": 4, "overwintered": 1, "txfee": 1000, + "protocol": {"type": "BCH", "protocol_data": {"slp_prefix": "slptest"}} + })); + coins_vec.push(json!({ + "coin": "ADEXSLP", + "protocol": {"type": "SLPTOKEN", "protocol_data": {"decimals": 8, "token_id": get_slp_token_id(), "platform": "FORSLP"}} + })); + } + + let coins = Json::Array(coins_vec); + let mut mm_bob = block_on(MarketMakerIt::start_with_envs( + json! ({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + envs.as_slice(), + )) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + block_on(mm_bob.wait_for_log(22., |log| log.contains(">>>>>>>>> DEX stats "))).unwrap(); + + let mut mm_alice = block_on(MarketMakerIt::start_with_envs( + json! ({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + envs.as_slice(), + )) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + block_on(mm_alice.wait_for_log(22., |log| log.contains(">>>>>>>>> DEX stats "))).unwrap(); + + // Enable coins for Bob based on enabled features + #[cfg(feature = "docker-tests-qrc20")] + { + log!("{:?}", block_on(enable_qrc20_native(&mm_bob, "QICK"))); + log!("{:?}", block_on(enable_qrc20_native(&mm_bob, "QORTY"))); + log!("{:?}", block_on(enable_native_qrc20(&mm_bob, "QTUM", &[], None))); + log!("{:?}", block_on(enable_native_qrc20(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native_qrc20(&mm_bob, "MYCOIN1", &[], None))); + } + + #[cfg(all( + any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-sia" + ), + not(feature = "docker-tests-qrc20") + ))] + { + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + } + + #[cfg(feature = "docker-tests-slp")] + { + log!("{:?}", block_on(enable_native_bch(&mm_bob, "FORSLP", &[]))); + log!("{:?}", block_on(enable_native_slp(&mm_bob, "ADEXSLP", &[], None))); + } + + #[cfg(feature = "docker-tests-eth")] + { + let swap_contract = swap_contract_checksum(); + log!( + "{:?}", + block_on(enable_eth_coin( + &mm_bob, + "ETH", + &[GETH_RPC_URL], + &swap_contract, + None, + false + )) + ); + log!( + "{:?}", + block_on(enable_eth_coin( + &mm_bob, + "ERC20DEV", + &[GETH_RPC_URL], + &swap_contract, + None, + false + )) + ); + } + + // Enable coins for Alice based on enabled features + #[cfg(feature = "docker-tests-qrc20")] + { + log!("{:?}", block_on(enable_qrc20_native(&mm_alice, "QICK"))); + log!("{:?}", block_on(enable_qrc20_native(&mm_alice, "QORTY"))); + log!("{:?}", block_on(enable_native_qrc20(&mm_alice, "QTUM", &[], None))); + log!("{:?}", block_on(enable_native_qrc20(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native_qrc20(&mm_alice, "MYCOIN1", &[], None))); + } + + #[cfg(all( + any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-sia" + ), + not(feature = "docker-tests-qrc20") + ))] + { + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + } + + #[cfg(feature = "docker-tests-slp")] + { + log!("{:?}", block_on(enable_native_bch(&mm_alice, "FORSLP", &[]))); + log!("{:?}", block_on(enable_native_slp(&mm_alice, "ADEXSLP", &[], None))); + } + + #[cfg(feature = "docker-tests-eth")] + { + let swap_contract = swap_contract_checksum(); + log!( + "{:?}", + block_on(enable_eth_coin( + &mm_alice, + "ETH", + &[GETH_RPC_URL], + &swap_contract, + None, + false + )) + ); + log!( + "{:?}", + block_on(enable_eth_coin( + &mm_alice, + "ERC20DEV", + &[GETH_RPC_URL], + &swap_contract, + None, + false + )) + ); + } + + let rc = block_on(mm_bob.rpc(&json! ({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": base, + "rel": rel, + "price": 1, + "volume": "3", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + thread::sleep(Duration::from_secs(1)); + + log!("Issue alice {}/{} buy request", base, rel); + let rc = block_on(mm_alice.rpc(&json! ({ + "userpass": mm_alice.userpass, + "method": "buy", + "base": base, + "rel": rel, + "price": 1, + "volume": "2", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + let buy_json: Json = serde_json::from_str(&rc.1).unwrap(); + let uuid = buy_json["result"]["uuid"].as_str().unwrap().to_owned(); + + // ensure the swaps are started + block_on(mm_bob.wait_for_log(22., |log| { + log.contains(&format!("Entering the maker_swap_loop {base}/{rel}")) + })) + .unwrap(); + block_on(mm_alice.wait_for_log(22., |log| { + log.contains(&format!("Entering the taker_swap_loop {base}/{rel}")) + })) + .unwrap(); + + // ensure the swaps are finished + block_on(mm_bob.wait_for_log(600., |log| log.contains(&format!("[swap uuid={uuid}] Finished")))).unwrap(); + block_on(mm_alice.wait_for_log(600., |log| log.contains(&format!("[swap uuid={uuid}] Finished")))).unwrap(); + + log!("Checking alice/taker status.."); + block_on(check_my_swap_status( + &mm_alice, + &uuid, + "2".parse().unwrap(), + "2".parse().unwrap(), + )); + + log!("Checking bob/maker status.."); + block_on(check_my_swap_status( + &mm_bob, + &uuid, + "2".parse().unwrap(), + "2".parse().unwrap(), + )); + + log!("Checking alice status.."); + block_on(wait_check_stats_swap_status(&mm_alice, &uuid, 240)); + + log!("Checking bob status.."); + block_on(wait_check_stats_swap_status(&mm_bob, &uuid, 240)); + + log!("Checking alice recent swaps.."); + block_on(check_recent_swaps(&mm_alice, 1)); + log!("Checking bob recent swaps.."); + block_on(check_recent_swaps(&mm_bob, 1)); + + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} diff --git a/mm2src/mm2_main/tests/docker_tests/helpers/tendermint.rs b/mm2src/mm2_main/tests/docker_tests/helpers/tendermint.rs new file mode 100644 index 0000000000..2f21209130 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/helpers/tendermint.rs @@ -0,0 +1,161 @@ +//! Tendermint/Cosmos helpers for docker tests. +//! +//! This module provides: +//! - Docker node helpers for Nucleus, Atom, and IBC relayer +//! - IBC channel preparation utilities + +use crate::docker_tests::helpers::env::{resolve_compose_container_id, DockerNode, KDF_IBC_RELAYER_SERVICE}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::Duration; +use testcontainers::core::Mount; +use testcontainers::runners::SyncRunner; +use testcontainers::{GenericImage, RunnableImage}; + +// ============================================================================= +// Docker image constants +// ============================================================================= + +/// Nucleus docker image +pub const NUCLEUS_IMAGE: &str = "docker.io/gleec/nucleusd"; +/// Atom (Gaia) docker image with tag +pub const ATOM_IMAGE_WITH_TAG: &str = "docker.io/gleec/gaiad:kdf-ci"; +/// IBC relayer docker image with tag +pub const IBC_RELAYER_IMAGE_WITH_TAG: &str = "docker.io/gleec/ibc-relayer:kdf-ci"; + +// ============================================================================= +// Docker node helpers +// ============================================================================= + +/// Start a Nucleus docker node for testing. +pub fn nucleus_node(runtime_dir: PathBuf) -> DockerNode { + let nucleus_node_runtime_dir = runtime_dir.join("nucleus-testnet-data"); + assert!(nucleus_node_runtime_dir.exists()); + + let image = GenericImage::new(NUCLEUS_IMAGE, "latest").with_mount(Mount::bind_mount( + nucleus_node_runtime_dir.to_str().unwrap(), + "/root/.nucleus", + )); + let image = RunnableImage::from((image, vec![])).with_network("host"); + let container = image.start().expect("Failed to start Nucleus docker node"); + + DockerNode { + container, + ticker: "NUCLEUS-TEST".to_owned(), + port: Default::default(), // This doesn't need to be the correct value as we are using the host network. + } +} + +/// Start an Atom (Gaia) docker node for testing. +pub fn atom_node(runtime_dir: PathBuf) -> DockerNode { + let atom_node_runtime_dir = runtime_dir.join("atom-testnet-data"); + assert!(atom_node_runtime_dir.exists()); + + let (image, tag) = ATOM_IMAGE_WITH_TAG.rsplit_once(':').unwrap(); + let image = GenericImage::new(image, tag).with_mount(Mount::bind_mount( + atom_node_runtime_dir.to_str().unwrap(), + "/root/.gaia", + )); + let image = RunnableImage::from((image, vec![])).with_network("host"); + let container = image.start().expect("Failed to start Atom docker node"); + + DockerNode { + container, + ticker: "ATOM-TEST".to_owned(), + port: Default::default(), // This doesn't need to be the correct value as we are using the host network. + } +} + +/// Start an IBC relayer docker node for testing. +pub fn ibc_relayer_node(runtime_dir: PathBuf) -> DockerNode { + let relayer_node_runtime_dir = runtime_dir.join("ibc-relayer-data"); + assert!(relayer_node_runtime_dir.exists()); + + let (image, tag) = IBC_RELAYER_IMAGE_WITH_TAG.rsplit_once(':').unwrap(); + let image = GenericImage::new(image, tag).with_mount(Mount::bind_mount( + relayer_node_runtime_dir.to_str().unwrap(), + "/root/.relayer", + )); + let image = RunnableImage::from((image, vec![])).with_network("host"); + let container = image.start().expect("Failed to start IBC Relayer docker node"); + + DockerNode { + container, + ticker: Default::default(), // This isn't an asset node. + port: Default::default(), // This doesn't need to be the correct value as we are using the host network. + } +} + +// ============================================================================= +// IBC utilities +// ============================================================================= + +/// Prepare IBC channels between Nucleus and Atom. +pub fn prepare_ibc_channels(container_id: &str) { + let exec = |args: &[&str]| { + Command::new("docker") + .args(["exec", container_id]) + .args(args) + .output() + .unwrap(); + }; + + exec(&["rly", "transact", "clients", "nucleus-atom", "--override"]); + // It takes a couple of seconds for nodes to get into the right state after updating clients. + // Wait for 5 just to make sure. + thread::sleep(Duration::from_secs(5)); + + exec(&["rly", "transact", "link", "nucleus-atom"]); +} + +/// Wait until the IBC relayer container is ready. +pub fn wait_until_relayer_container_is_ready(container_id: &str) { + const Q_RESULT: &str = "0: nucleus-atom -> chns(βœ”) clnts(βœ”) conn(βœ”) (nucleus-testnet<>cosmoshub-testnet)"; + + let mut attempts = 0; + loop { + let mut docker = Command::new("docker"); + docker.arg("exec").arg(container_id).args(["rly", "paths", "list"]); + + log!("Running <<{docker:?}>>."); + + let output = docker.stderr(Stdio::inherit()).output().unwrap(); + let output = String::from_utf8(output.stdout).unwrap(); + let output = output.trim(); + + if output == Q_RESULT { + break; + } + attempts += 1; + + log!("Expected output {Q_RESULT}, received {output}."); + if attempts > 10 { + panic!("Reached max attempts for <<{:?}>>.", docker); + } else { + log!("Asking for relayer node status again.."); + } + + thread::sleep(Duration::from_secs(2)); + } +} + +// ============================================================================= +// Compose mode utilities +// ============================================================================= + +/// Prepare IBC channels for compose mode. +/// +/// Resolves the IBC relayer container ID from docker-compose and prepares channels. +pub fn prepare_ibc_channels_compose() { + let container_id = resolve_compose_container_id(KDF_IBC_RELAYER_SERVICE); + prepare_ibc_channels(&container_id); +} + +/// Wait for IBC relayer to be ready in compose mode. +/// +/// Resolves the IBC relayer container ID from docker-compose and waits for readiness. +pub fn wait_until_relayer_container_is_ready_compose() { + let container_id = resolve_compose_container_id(KDF_IBC_RELAYER_SERVICE); + wait_until_relayer_container_is_ready(&container_id); +} diff --git a/mm2src/mm2_main/tests/docker_tests/helpers/utxo.rs b/mm2src/mm2_main/tests/docker_tests/helpers/utxo.rs new file mode 100644 index 0000000000..e6c69f109b --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/helpers/utxo.rs @@ -0,0 +1,335 @@ +//! UTXO coin helpers for docker tests. +//! +//! This module provides: +//! - UTXO asset docker node helpers (MYCOIN, MYCOIN1) +//! - Coin creation and funding utilities +//! +//! Note: BCH/SLP helpers are in the separate `slp` module. + +// ============================================================================= +// Imports +// ============================================================================= + +use crate::docker_tests::helpers::docker_ops::CoinDockerOps; +use crate::docker_tests::helpers::env::DockerNode; +use coins::utxo::rpc_clients::{UtxoRpcClientEnum, UtxoRpcClientOps}; +use coins::utxo::utxo_standard::{utxo_standard_coin_with_priv_key, UtxoStandardCoin}; +use coins::utxo::{coin_daemon_data_dir, zcash_params_path, UtxoActivationParams, UtxoCoinFields}; +use coins::{ConfirmPaymentInput, MarketCoinOps}; +use common::executor::Timer; +use common::Future01CompatExt; +use common::{block_on, now_ms, now_sec, wait_until_ms, wait_until_sec}; +use crypto::Secp256k1Secret; +use mm2_core::mm_ctx::{MmArc, MmCtxBuilder}; +use mm2_number::BigDecimal; +use serde_json::json; +use std::process::Command; +use testcontainers::core::Mount; +use testcontainers::runners::SyncRunner; +use testcontainers::GenericImage; +use testcontainers::{core::WaitFor, RunnableImage}; +use tokio::sync::Mutex as AsyncMutex; + +// rmd160_from_priv imports (only for ordermatch/swaps-utxo) +#[cfg(any(feature = "docker-tests-ordermatch", feature = "docker-tests-swaps"))] +use bitcrypto::dhash160; +#[cfg(any(feature = "docker-tests-ordermatch", feature = "docker-tests-swaps"))] +use primitives::hash::H160; + +// random_secp256k1_secret import (only for features using generate_utxo_coin_with_random_privkey) +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers" +))] +use crate::docker_tests::helpers::env::random_secp256k1_secret; + +// ============================================================================= +// Funding Locks +// ============================================================================= + +lazy_static! { + /// Lock for MYCOIN funding operations + pub static ref MYCOIN_LOCK: AsyncMutex<()> = AsyncMutex::new(()); + + /// Lock for MYCOIN1 funding operations + pub static ref MYCOIN1_LOCK: AsyncMutex<()> = AsyncMutex::new(()); + + /// Lock for Qtum/QRC20 funding operations. + /// Shared by QTUM, QICK, and QORTY coins since they all run on the same Qtum node. + pub static ref QTUM_LOCK: AsyncMutex<()> = AsyncMutex::new(()); +} + +/// Get the appropriate funding lock for a given ticker. +fn get_funding_lock(ticker: &str) -> &'static AsyncMutex<()> { + match ticker { + "MYCOIN" => &MYCOIN_LOCK, + "MYCOIN1" => &MYCOIN1_LOCK, + "QTUM" | "QICK" | "QORTY" => &QTUM_LOCK, + _ => panic!("No funding lock defined for ticker: {}", ticker), + } +} + +// ============================================================================= +// Docker image constants +// ============================================================================= + +/// UTXO asset docker image +pub const UTXO_ASSET_DOCKER_IMAGE: &str = "docker.io/gleec/testblockchain"; +/// UTXO asset docker image with tag +pub const UTXO_ASSET_DOCKER_IMAGE_WITH_TAG: &str = "docker.io/gleec/testblockchain:multiarch"; + +// ============================================================================= +// Ticker constants (UTXO asset features only) +// ============================================================================= + +/// Ticker of MYCOIN dockerized blockchain. +#[cfg(feature = "docker-tests-swaps")] +pub const MYCOIN: &str = "MYCOIN"; + +/// Ticker of MYCOIN1 dockerized blockchain. +#[cfg(feature = "docker-tests-swaps")] +pub const MYCOIN1: &str = "MYCOIN1"; + +// ============================================================================= +// UtxoAssetDockerOps +// ============================================================================= + +/// Docker operations for standard UTXO assets (MYCOIN, MYCOIN1). +pub struct UtxoAssetDockerOps { + #[allow(dead_code)] + ctx: MmArc, + coin: UtxoStandardCoin, +} + +impl CoinDockerOps for UtxoAssetDockerOps { + fn rpc_client(&self) -> &UtxoRpcClientEnum { + &self.coin.as_ref().rpc_client + } +} + +impl UtxoAssetDockerOps { + /// Create UtxoAssetDockerOps from ticker. + pub fn from_ticker(ticker: &str) -> UtxoAssetDockerOps { + let conf = json!({"coin": ticker, "asset": ticker, "txfee": 1000, "network": "regtest"}); + let req = json!({"method":"enable"}); + let priv_key = Secp256k1Secret::from("809465b17d0a4ddb3e4c69e8f23c2cabad868f51f8bed5c765ad1d6516c3306f"); + let ctx = MmCtxBuilder::new().into_mm_arc(); + let params = UtxoActivationParams::from_legacy_req(&req).unwrap(); + + let coin = block_on(utxo_standard_coin_with_priv_key(&ctx, ticker, &conf, ¶ms, priv_key)).unwrap(); + UtxoAssetDockerOps { ctx, coin } + } +} + +// ============================================================================= +// Docker node helpers +// ============================================================================= + +/// Start a UTXO asset docker node. +pub fn utxo_asset_docker_node(ticker: &'static str, port: u16) -> DockerNode { + let image = GenericImage::new(UTXO_ASSET_DOCKER_IMAGE, "multiarch") + .with_mount(Mount::bind_mount( + zcash_params_path().display().to_string(), + "/root/.zcash-params", + )) + .with_env_var("CLIENTS", "2") + .with_env_var("CHAIN", ticker) + .with_env_var("TEST_ADDY", "R9imXLs1hEcU9KbFDQq2hJEEJ1P5UoekaF") + .with_env_var("TEST_WIF", "UqqW7f766rADem9heD8vSBvvrdfJb3zg5r8du9rJxPtccjWf7RG9") + .with_env_var( + "TEST_PUBKEY", + "021607076d7a2cb148d542fb9644c04ffc22d2cca752f80755a0402a24c567b17a", + ) + .with_env_var("DAEMON_URL", "http://test:test@127.0.0.1:7000") + .with_env_var("COIN", "Komodo") + .with_env_var("COIN_RPC_PORT", port.to_string()) + .with_wait_for(WaitFor::message_on_stdout("config is ready")); + let image = RunnableImage::from(image).with_mapped_port((port, port)); + let container = image.start().expect("Failed to start UTXO asset docker node"); + let mut conf_path = coin_daemon_data_dir(ticker, true); + std::fs::create_dir_all(&conf_path).unwrap(); + conf_path.push(format!("{ticker}.conf")); + Command::new("docker") + .arg("cp") + .arg(format!("{}:/data/node_0/{}.conf", container.id(), ticker)) + .arg(&conf_path) + .status() + .expect("Failed to execute docker command"); + let timeout = wait_until_ms(3000); + loop { + if conf_path.exists() { + break; + }; + assert!(now_ms() < timeout, "Test timed out"); + } + DockerNode { + container, + ticker: ticker.into(), + port, + } +} + +// ============================================================================= +// Coin creation and funding utilities +// ============================================================================= + +/// Compute RIPEMD160(SHA256(pubkey)) from a private key. +#[cfg(any(feature = "docker-tests-ordermatch", feature = "docker-tests-swaps"))] +pub fn rmd160_from_priv(privkey: Secp256k1Secret) -> H160 { + use secp256k1::{PublicKey, Secp256k1, SecretKey}; + let secret = SecretKey::from_slice(privkey.as_slice()).unwrap(); + let public = PublicKey::from_secret_key(&Secp256k1::new(), &secret); + dhash160(&public.serialize()) +} + +/// Import an address to the coin's wallet. +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-integration" +))] +pub async fn import_address(coin: &T) +where + T: MarketCoinOps + AsRef, +{ + let mutex = get_funding_lock(coin.ticker()); + let _lock = mutex.lock().await; + + match coin.as_ref().rpc_client { + UtxoRpcClientEnum::Native(ref native) => { + let my_address = coin.my_address().unwrap(); + native + .import_address(&my_address, &my_address, false) + .compat() + .await + .unwrap(); + }, + UtxoRpcClientEnum::Electrum(_) => panic!("Expected NativeClient"), + } +} + +/// Build asset `UtxoStandardCoin` from ticker and privkey without filling the balance. +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-integration" +))] +pub fn utxo_coin_from_privkey(ticker: &str, priv_key: Secp256k1Secret) -> (MmArc, UtxoStandardCoin) { + let ctx = MmCtxBuilder::new().into_mm_arc(); + let conf = json!({"coin":ticker,"asset":ticker,"txversion":4,"overwintered":1,"txfee":1000,"network":"regtest"}); + let req = json!({"method":"enable"}); + let params = UtxoActivationParams::from_legacy_req(&req).unwrap(); + let coin = block_on(utxo_standard_coin_with_priv_key(&ctx, ticker, &conf, ¶ms, priv_key)).unwrap(); + block_on(import_address(&coin)); + (ctx, coin) +} + +/// Create a UTXO coin for the given privkey and fill its address with the specified balance. +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-integration" +))] +pub fn generate_utxo_coin_with_privkey(ticker: &str, balance: BigDecimal, priv_key: Secp256k1Secret) { + let (_, coin) = utxo_coin_from_privkey(ticker, priv_key); + let timeout = 30; // timeout if test takes more than 30 seconds to run + let my_address = coin.my_address().expect("!my_address"); + fill_address(&coin, &my_address, balance, timeout); +} + +/// Fund a UTXO address with the specified balance (async version). +/// Only used by Sia tests which need async funding. +#[cfg(feature = "docker-tests-sia")] +pub async fn fund_privkey_utxo(ticker: &str, balance: BigDecimal, priv_key: &Secp256k1Secret) { + let ctx = MmCtxBuilder::new().into_mm_arc(); + let conf = json!({"coin":ticker,"asset":ticker,"txversion":4,"overwintered":1,"txfee":1000,"network":"regtest"}); + let req = json!({"method":"enable"}); + let params = UtxoActivationParams::from_legacy_req(&req).unwrap(); + let coin = utxo_standard_coin_with_priv_key(&ctx, ticker, &conf, ¶ms, *priv_key) + .await + .unwrap(); + let my_address = coin.my_address().expect("!my_address"); + fill_address_async(&coin, &my_address, balance, 30).await; +} + +/// Generate random privkey, create a UTXO coin and fill its address with the specified balance. +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers" +))] +pub fn generate_utxo_coin_with_random_privkey( + ticker: &str, + balance: BigDecimal, +) -> (MmArc, UtxoStandardCoin, Secp256k1Secret) { + let priv_key = random_secp256k1_secret(); + let (ctx, coin) = utxo_coin_from_privkey(ticker, priv_key); + let timeout = 30; // timeout if test takes more than 30 seconds to run + let my_address = coin.my_address().expect("!my_address"); + fill_address(&coin, &my_address, balance, timeout); + (ctx, coin, priv_key) +} + +/// Fill address with the specified amount (synchronous wrapper). +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-integration" +))] +pub fn fill_address(coin: &T, address: &str, amount: BigDecimal, timeout: u64) +where + T: MarketCoinOps + AsRef, +{ + block_on(fill_address_async(coin, address, amount, timeout)); +} + +/// Fill address with the specified amount (async version). +pub async fn fill_address_async(coin: &T, address: &str, amount: BigDecimal, timeout: u64) +where + T: MarketCoinOps + AsRef, +{ + // prevent concurrent fill since daemon RPC returns errors if send_to_address + // is called concurrently (insufficient funds) and it also may return other errors + // if previous transaction is not confirmed yet + let mutex = get_funding_lock(coin.ticker()); + let _lock = mutex.lock().await; + let timeout = wait_until_sec(timeout); + + if let UtxoRpcClientEnum::Native(client) = &coin.as_ref().rpc_client { + client.import_address(address, address, false).compat().await.unwrap(); + let hash = client.send_to_address(address, &amount).compat().await.unwrap(); + let tx_bytes = client.get_transaction_bytes(&hash).compat().await.unwrap(); + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: tx_bytes.clone().0, + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + coin.wait_for_confirmations(confirm_payment_input) + .compat() + .await + .unwrap(); + log!("{:02x}", tx_bytes); + loop { + let unspents = client + .list_unspent_impl(0, i32::MAX, vec![address.to_string()]) + .compat() + .await + .unwrap(); + if !unspents.is_empty() { + break; + } + assert!(now_sec() < timeout, "Test timed out"); + Timer::sleep(1.0).await; + } + }; +} diff --git a/mm2src/mm2_main/tests/docker_tests/helpers/zcoin.rs b/mm2src/mm2_main/tests/docker_tests/helpers/zcoin.rs new file mode 100644 index 0000000000..6aef52af4e --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/helpers/zcoin.rs @@ -0,0 +1,153 @@ +//! ZCoin helpers for docker tests. +//! +//! This module provides: +//! - ZCoin docker operations (ZCoinAssetDockerOps) +//! - Zombie asset docker node helpers +//! - ZCoin creation utilities + +use crate::docker_tests::helpers::docker_ops::CoinDockerOps; +use crate::docker_tests::helpers::env::DockerNode; +use coins::utxo::rpc_clients::UtxoRpcClientEnum; +use coins::utxo::{coin_daemon_data_dir, zcash_params_path}; +use coins::z_coin::ZCoin; +use common::{block_on, now_ms, wait_until_ms}; +use mm2_core::mm_ctx::MmArc; +use serde_json::json; +use std::process::Command; +use std::sync::Mutex; +use testcontainers::core::Mount; +use testcontainers::runners::SyncRunner; +use testcontainers::{core::WaitFor, GenericImage, RunnableImage}; + +// ============================================================================= +// Docker image constants +// ============================================================================= + +/// Zombie asset docker image +pub const ZOMBIE_ASSET_DOCKER_IMAGE: &str = "docker.io/gleec/zombietestrunner"; +/// Zombie asset docker image with tag +pub const ZOMBIE_ASSET_DOCKER_IMAGE_WITH_TAG: &str = "docker.io/gleec/zombietestrunner:multiarch"; + +// ============================================================================= +// ZCoinAssetDockerOps +// ============================================================================= + +/// Docker operations for ZCoin/Zombie assets. +pub struct ZCoinAssetDockerOps { + #[allow(dead_code)] + ctx: MmArc, + coin: ZCoin, +} + +impl CoinDockerOps for ZCoinAssetDockerOps { + fn rpc_client(&self) -> &UtxoRpcClientEnum { + &self.coin.as_ref().rpc_client + } +} + +impl ZCoinAssetDockerOps { + /// Create ZCoinAssetDockerOps with default settings. + pub fn new() -> ZCoinAssetDockerOps { + let (ctx, coin) = block_on(z_coin_from_spending_key( + "secret-extended-key-main1q0k2ga2cqqqqpq8m8j6yl0say83cagrqp53zqz54w38ezs8ly9ly5ptamqwfpq85u87w0df4k8t2lwyde3n9v0gcr69nu4ryv60t0kfcsvkr8h83skwqex2nf0vr32794fmzk89cpmjptzc22lgu5wfhhp8lgf3f5vn2l3sge0udvxnm95k6dtxj2jwlfyccnum7nz297ecyhmd5ph526pxndww0rqq0qly84l635mec0x4yedf95hzn6kcgq8yxts26k98j9g32kjc8y83fe", + "fe", + )); + + ZCoinAssetDockerOps { ctx, coin } + } +} + +// ============================================================================= +// Statics for ZCoin tests +// ============================================================================= + +lazy_static! { + /// Temporary directory for ZCoin databases (created once, cleaned up on process exit) + pub static ref TEMP_DIR: Mutex = Mutex::new(tempfile::TempDir::new().unwrap()); +} + +// ============================================================================= +// Docker node helpers +// ============================================================================= + +/// Start a Zombie asset docker node for testing. +pub fn zombie_asset_docker_node(port: u16) -> DockerNode { + let image = GenericImage::new(ZOMBIE_ASSET_DOCKER_IMAGE, "multiarch") + .with_mount(Mount::bind_mount( + zcash_params_path().display().to_string(), + "/root/.zcash-params", + )) + .with_env_var("COIN_RPC_PORT", port.to_string()) + .with_wait_for(WaitFor::message_on_stdout("config is ready")); + + let image = RunnableImage::from(image).with_mapped_port((port, port)); + let container = image.start().expect("Failed to start Zombie asset docker node"); + let config_ticker = "ZOMBIE"; + let mut conf_path = coin_daemon_data_dir(config_ticker, true); + + std::fs::create_dir_all(&conf_path).unwrap(); + conf_path.push(format!("{config_ticker}.conf")); + Command::new("docker") + .arg("cp") + .arg(format!("{}:/data/node_0/{}.conf", container.id(), config_ticker)) + .arg(&conf_path) + .status() + .expect("Failed to execute docker command"); + + let timeout = wait_until_ms(3000); + while !conf_path.exists() { + assert!(now_ms() < timeout, "Test timed out"); + } + + DockerNode { + container, + ticker: config_ticker.into(), + port, + } +} + +// ============================================================================= +// ZCoin creation utilities +// ============================================================================= + +/// Build asset `ZCoin` from ticker and spending_key. +pub async fn z_coin_from_spending_key(spending_key: &str, path: &str) -> (MmArc, ZCoin) { + use coins::z_coin::{z_coin_from_conf_and_params_with_docker, ZcoinActivationParams, ZcoinRpcMode}; + use coins::{CoinProtocol, PrivKeyBuildPolicy}; + use mm2_core::mm_ctx::MmCtxBuilder; + use mm2_test_helpers::for_tests::zombie_conf_for_docker; + + let db_path = { + let tmp = TEMP_DIR.lock().unwrap(); + let path = tmp.path().join(format!("ZOMBIE_DB_{path}")); + std::fs::create_dir_all(&path).unwrap(); + path + }; + let ctx = MmCtxBuilder::new().with_conf(json!({ "dbdir": db_path})).into_mm_arc(); + + let mut conf = zombie_conf_for_docker(); + let params = ZcoinActivationParams { + mode: ZcoinRpcMode::Native, + ..Default::default() + }; + let pk_data = [1; 32]; + + let protocol_info = match serde_json::from_value::(conf["protocol"].take()).unwrap() { + CoinProtocol::ZHTLC(protocol_info) => protocol_info, + other_protocol => panic!("Failed to get protocol from config: {:?}", other_protocol), + }; + + let coin = z_coin_from_conf_and_params_with_docker( + &ctx, + "ZOMBIE", + &conf, + ¶ms, + PrivKeyBuildPolicy::IguanaPrivKey(pk_data.into()), + protocol_info, + spending_key, + ) + .await + .unwrap(); + + (ctx, coin) +} diff --git a/mm2src/mm2_main/tests/docker_tests/mod.rs b/mm2src/mm2_main/tests/docker_tests/mod.rs index e9b07c3c84..ecd830ef86 100644 --- a/mm2src/mm2_main/tests/docker_tests/mod.rs +++ b/mm2src/mm2_main/tests/docker_tests/mod.rs @@ -1,17 +1,145 @@ #![allow(static_mut_refs)] -pub mod docker_tests_common; +pub mod runner; + +// Helpers are used by all docker tests +#[cfg(feature = "run-docker-tests")] +pub mod helpers; + +// ============================================================================ +// ORDERMATCHING TESTS +// Tests for the orderbook and order matching engine (lp_ordermatch) +// Future destination: mm2_main::lp_ordermatch/tests +// ============================================================================ + +// Ordermatching tests - UTXO-only orderbook +// Tests: best_orders, orderbook depth, price aggregation, custom orderbook tickers +// Chains: UTXO-MYCOIN, UTXO-MYCOIN1 +#[cfg(feature = "docker-tests-ordermatch")] mod docker_ordermatch_tests; + +// UTXO Ordermatching V1 tests - UTXO-only orderbook mechanics (extracted from docker_tests_inner) +// Tests: order lifecycle, balance-driven cancellations/updates, restart kickstart, best-price matching, +// RPC response formats, min_volume/dust validation, P2P time sync validation +// Chains: UTXO-MYCOIN, UTXO-MYCOIN1 +#[cfg(feature = "docker-tests-ordermatch")] +mod utxo_ordermatch_v1_tests; + +// ============================================================================ +// SWAP TESTS +// Tests for atomic swap execution (lp_swap) +// Future destination: mm2_main::lp_swap/tests or coins::*/tests +// ============================================================================ + +// Cross-chain tests - UTXO + ETH cross-chain order matching and validation +// Tests: cross-chain order matching, volume validation, orderbook depth, best_orders +// Chains: UTXO-MYCOIN, UTXO-MYCOIN1, ETH, ERC20 +// Note: Contains tests that require BOTH ETH and UTXO chains simultaneously +#[cfg(feature = "docker-tests-integration")] mod docker_tests_inner; + +// ETH Inner tests - ETH-only tests (extracted from docker_tests_inner) +// Tests: ETH/ERC20 activation, disable, withdraw, swap contract negotiation, order management, ERC20 approval +// Chains: ETH, ERC20 +// Future: Consider separate feature flag (docker-tests-eth-only) for tests that don't need UTXO +#[cfg(feature = "docker-tests-eth")] +mod eth_inner_tests; + +// Swap protocol v2 tests - UTXO-only TPU protocol +// Tests: MakerSwapStateMachine, TakerSwapStateMachine, trading protocol upgrade +// Chains: UTXO-MYCOIN, UTXO-MYCOIN1 +#[cfg(feature = "docker-tests-swaps")] +mod swap_proto_v2_tests; + +// UTXO Swaps V1 tests - UTXO-only swap mechanics (extracted from docker_tests_inner) +// Tests: swap spend/refund, trade preimage, max taker/maker vol, locked amounts, UTXO merge +// Chains: UTXO-MYCOIN, UTXO-MYCOIN1 +#[cfg(feature = "docker-tests-swaps")] +mod utxo_swaps_v1_tests; + +// Swap confirmation settings sync tests - UTXO-only +// Tests: confirmation requirements, settings synchronization between maker/taker +// Chains: UTXO-MYCOIN, UTXO-MYCOIN1 +#[cfg(feature = "docker-tests-swaps")] +mod swaps_confs_settings_sync_tests; + +// Swap file lock tests - UTXO-only infrastructure +// Tests: concurrent swap file locking, race condition prevention +// Chains: UTXO-MYCOIN, UTXO-MYCOIN1 +#[cfg(feature = "docker-tests-swaps")] +mod swaps_file_lock_tests; + +// ============================================================================ +// CROSS-CHAIN INTEGRATION TESTS +// Tests for atomic swaps between different chain families (requires all containers) +// Future destination: Integration test suite +// ============================================================================ + +// BCH-SLP swap tests +// Tests: BCH/SLP atomic swaps (FORSLP, ADEXSLP pairs) +// Chains: BCH-SLP (FORSLP node only) +#[cfg(feature = "docker-tests-slp")] +mod swap_tests; + +// ============================================================================ +// WATCHER TESTS +// Tests for swap watcher nodes (lp_swap::watchers) +// Future destination: mm2_main::lp_swap::watchers/tests +// ============================================================================ + +// Swap watcher tests. +// UTXO watcher tests are enabled with `docker-tests-watchers`. +// ETH/ERC20 watcher tests are behind `docker-tests-watchers-eth` (disabled by default). +#[cfg(feature = "docker-tests-watchers")] +mod swap_watcher_tests; + +// ============================================================================ +// COIN-SPECIFIC TESTS +// Tests for individual coin implementations (coins crate) +// Future destination: coins::*/tests +// ============================================================================ + +// ETH/ERC20 coin tests +// Tests: gas estimation, nonce management, ERC20 activation, NFT swaps +// Chains: ETH, ERC20, ERC721, ERC1155 +#[cfg(feature = "docker-tests-eth")] mod eth_docker_tests; + +// QRC20 coin and swap tests +// Tests: QRC20 activation, QTUM gas, QRC20<->UTXO swaps +// Chains: QRC20, UTXO-MYCOIN +#[cfg(feature = "docker-tests-qrc20")] pub mod qrc20_tests; + +// SIA coin tests +// Tests: Sia activation, balance, withdraw +// Chains: Sia +#[cfg(feature = "docker-tests-sia")] mod sia_docker_tests; + +// SLP/BCH coin tests +// Tests: SLP token activation, BCH-SLP balance +// Chains: BCH-SLP (FORSLP, ADEXSLP) +#[cfg(feature = "docker-tests-slp")] mod slp_tests; -mod swap_proto_v2_tests; -mod swap_watcher_tests; -mod swaps_confs_settings_sync_tests; -mod swaps_file_lock_tests; + +// Tendermint coin and IBC tests (Cosmos-only) +// Tests: ATOM/Nucleus/IRIS activation, staking, IBC transfers, withdraw, delegation +// Chains: Tendermint (ATOM, Nucleus, IRIS) +#[cfg(feature = "docker-tests-tendermint")] mod tendermint_tests; + +// Tendermint cross-chain swap tests +// Tests: NUCLEUS<->DOC, NUCLEUS<->ETH, DOC<->IRIS-IBC-NUCLEUS swaps +// Chains: Tendermint (NUCLEUS, IRIS) + ETH/Electrum +// Note: Requires multiple chain families (Tendermint + ETH) - part of integration test suite +#[cfg(feature = "docker-tests-integration")] +mod tendermint_swap_tests; + +// ZCoin/Zombie coin tests +// Tests: ZCoin activation, shielded transactions, DEX fee collection +// Chains: ZCoin/Zombie +#[cfg(feature = "docker-tests-zcoin")] mod z_coin_docker_tests; // dummy test helping IDE to recognize this as test module diff --git a/mm2src/mm2_main/tests/docker_tests/qrc20_tests.rs b/mm2src/mm2_main/tests/docker_tests/qrc20_tests.rs index b39aeb1f8d..bcbff26787 100644 --- a/mm2src/mm2_main/tests/docker_tests/qrc20_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/qrc20_tests.rs @@ -1,131 +1,42 @@ -use crate::docker_tests::docker_tests_common::*; +use crate::docker_tests::helpers::env::random_secp256k1_secret; +use crate::docker_tests::helpers::qrc20::{ + enable_qrc20_native, fill_qrc20_address, generate_qrc20_coin_with_random_privkey, + generate_qtum_coin_with_random_privkey, generate_segwit_qtum_coin_with_random_privkey, qick_token_address, + qrc20_coin_from_privkey, qtum_conf_path, wait_for_estimate_smart_fee, +}; +use crate::docker_tests::helpers::swap::trade_base_rel; +use crate::docker_tests::helpers::utxo::{fill_address, utxo_coin_from_privkey}; use crate::integration_tests_common::enable_native; use bitcrypto::dhash160; -use coins::qrc20::rpc_clients::for_tests::Qrc20NativeWalletOps; -use coins::utxo::qtum::{qtum_coin_with_priv_key, QtumCoin}; -use coins::utxo::rpc_clients::UtxoRpcClientEnum; +use coins::utxo::qtum::QtumCoin; use coins::utxo::utxo_common::big_decimal_from_sat; -use coins::utxo::{UtxoActivationParams, UtxoCommonOps}; +use coins::utxo::UtxoCommonOps; use coins::{ CheckIfMyPaymentSentArgs, ConfirmPaymentInput, DexFee, DexFeeBurnDestination, FeeApproxStage, FoundSwapTxSpend, MarketCoinOps, MmCoin, RefundPaymentArgs, SearchForSwapTxSpendInput, SendPaymentArgs, SpendPaymentArgs, SwapOps, SwapTxTypeWithSecretHash, TradePreimageValue, TransactionEnum, ValidateFeeArgs, ValidatePaymentInput, WaitForHTLCTxSpendArgs, }; -use common::{block_on_f01, temp_dir, DEX_FEE_ADDR_RAW_PUBKEY}; -use crypto::Secp256k1Secret; -use ethereum_types::H160; +use common::{block_on, now_sec, wait_until_sec}; +use common::{block_on_f01, DEX_FEE_ADDR_RAW_PUBKEY}; use http::StatusCode; -use mm2_core::mm_ctx::{MmArc, MmCtxBuilder}; use mm2_main::lp_swap::max_taker_vol_from_available; use mm2_number::BigDecimal; +use mm2_number::MmNumber; use mm2_rpc::data::legacy::{CoinInitResponse, OrderbookResponse}; +use mm2_test_helpers::for_tests::{mm_dump, MarketMakerIt}; use mm2_test_helpers::structs::{trade_preimage_error, RpcErrorResponse, RpcSuccessResponse, TransactionDetails}; use rand6::Rng; -use serde_json::{self as json, Value as Json}; +use serde_json::{self as json, json, Value as Json}; use std::convert::TryFrom; -use std::process::Command; +use std::env; use std::str::FromStr; use std::sync::Mutex; +use std::thread; use std::time::Duration; -use testcontainers::core::WaitFor; -use testcontainers::runners::SyncRunner; -use testcontainers::{GenericImage, RunnableImage}; - -pub const QTUM_REGTEST_DOCKER_IMAGE: &str = "docker.io/sergeyboyko/qtumregtest"; -pub const QTUM_REGTEST_DOCKER_IMAGE_WITH_TAG: &str = "docker.io/sergeyboyko/qtumregtest:latest"; -const QRC20_TOKEN_BYTES: &str = "6080604052600860ff16600a0a633b9aca000260005534801561002157600080fd5b50600054600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002081905550610c69806100776000396000f3006080604052600436106100a4576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806306fdde03146100a9578063095ea7b31461013957806318160ddd1461019e57806323b872dd146101c9578063313ce5671461024e5780635a3b7e421461027f57806370a082311461030f57806395d89b4114610366578063a9059cbb146103f6578063dd62ed3e1461045b575b600080fd5b3480156100b557600080fd5b506100be6104d2565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156100fe5780820151818401526020810190506100e3565b50505050905090810190601f16801561012b5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561014557600080fd5b50610184600480360381019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291908035906020019092919050505061050b565b604051808215151515815260200191505060405180910390f35b3480156101aa57600080fd5b506101b36106bb565b6040518082815260200191505060405180910390f35b3480156101d557600080fd5b50610234600480360381019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803590602001909291905050506106c1565b604051808215151515815260200191505060405180910390f35b34801561025a57600080fd5b506102636109a1565b604051808260ff1660ff16815260200191505060405180910390f35b34801561028b57600080fd5b506102946109a6565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156102d45780820151818401526020810190506102b9565b50505050905090810190601f1680156103015780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561031b57600080fd5b50610350600480360381019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506109df565b6040518082815260200191505060405180910390f35b34801561037257600080fd5b5061037b6109f7565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156103bb5780820151818401526020810190506103a0565b50505050905090810190601f1680156103e85780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b34801561040257600080fd5b50610441600480360381019080803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080359060200190929190505050610a30565b604051808215151515815260200191505060405180910390f35b34801561046757600080fd5b506104bc600480360381019080803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050610be1565b6040518082815260200191505060405180910390f35b6040805190810160405280600881526020017f515243205445535400000000000000000000000000000000000000000000000081525081565b60008260008173ffffffffffffffffffffffffffffffffffffffff161415151561053457600080fd5b60008314806105bf57506000600260003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054145b15156105ca57600080fd5b82600260003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055508373ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925856040518082815260200191505060405180910390a3600191505092915050565b60005481565b60008360008173ffffffffffffffffffffffffffffffffffffffff16141515156106ea57600080fd5b8360008173ffffffffffffffffffffffffffffffffffffffff161415151561071157600080fd5b610797600260008873ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205485610c06565b600260008873ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002081905550610860600160008873ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205485610c06565b600160008873ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055506108ec600160008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205485610c1f565b600160008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055508473ffffffffffffffffffffffffffffffffffffffff168673ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef866040518082815260200191505060405180910390a36001925050509392505050565b600881565b6040805190810160405280600981526020017f546f6b656e20302e31000000000000000000000000000000000000000000000081525081565b60016020528060005260406000206000915090505481565b6040805190810160405280600381526020017f515443000000000000000000000000000000000000000000000000000000000081525081565b60008260008173ffffffffffffffffffffffffffffffffffffffff1614151515610a5957600080fd5b610aa2600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205484610c06565b600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002081905550610b2e600160008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205484610c1f565b600160008673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055508373ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef856040518082815260200191505060405180910390a3600191505092915050565b6002602052816000526040600020602052806000526040600020600091509150505481565b6000818310151515610c1457fe5b818303905092915050565b6000808284019050838110151515610c3357fe5b80915050929150505600a165627a7a723058207f2e5248b61b80365ea08a0f6d11ac0b47374c4dfd538de76bc2f19591bbbba40029"; -const QRC20_SWAP_CONTRACT_BYTES: &str = "608060405234801561001057600080fd5b50611437806100206000396000f3fe60806040526004361061004a5760003560e01c806302ed292b1461004f5780630716326d146100de578063152cf3af1461017b57806346fc0294146101f65780639b415b2a14610294575b600080fd5b34801561005b57600080fd5b506100dc600480360360a081101561007257600080fd5b81019080803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050610339565b005b3480156100ea57600080fd5b506101176004803603602081101561010157600080fd5b8101908080359060200190929190505050610867565b60405180846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526020018367ffffffffffffffff1667ffffffffffffffff16815260200182600381111561016557fe5b60ff168152602001935050505060405180910390f35b6101f46004803603608081101561019157600080fd5b8101908080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080356bffffffffffffffffffffffff19169060200190929190803567ffffffffffffffff1690602001909291905050506108bf565b005b34801561020257600080fd5b50610292600480360360a081101561021957600080fd5b81019080803590602001909291908035906020019092919080356bffffffffffffffffffffffff19169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050610bd9565b005b610337600480360360c08110156102aa57600080fd5b810190808035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080356bffffffffffffffffffffffff19169060200190929190803567ffffffffffffffff169060200190929190505050610fe2565b005b6001600381111561034657fe5b600080878152602001908152602001600020600001601c9054906101000a900460ff16600381111561037457fe5b1461037e57600080fd5b6000600333836003600288604051602001808281526020019150506040516020818303038152906040526040518082805190602001908083835b602083106103db57805182526020820191506020810190506020830392506103b8565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa15801561041d573d6000803e3d6000fd5b5050506040513d602081101561043257600080fd5b8101908080519060200190929190505050604051602001808281526020019150506040516020818303038152906040526040518082805190602001908083835b602083106104955780518252602082019150602081019050602083039250610472565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa1580156104d7573d6000803e3d6000fd5b5050506040515160601b8689604051602001808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b81526014018573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526014018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401828152602001955050505050506040516020818303038152906040526040518082805190602001908083835b602083106105fc57805182526020820191506020810190506020830392506105d9565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa15801561063e573d6000803e3d6000fd5b5050506040515160601b905060008087815260200190815260200160002060000160009054906101000a900460601b6bffffffffffffffffffffffff1916816bffffffffffffffffffffffff19161461069657600080fd5b6002600080888152602001908152602001600020600001601c6101000a81548160ff021916908360038111156106c857fe5b0217905550600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff16141561074e573373ffffffffffffffffffffffffffffffffffffffff166108fc869081150290604051600060405180830381858888f19350505050158015610748573d6000803e3d6000fd5b50610820565b60008390508073ffffffffffffffffffffffffffffffffffffffff1663a9059cbb33886040518363ffffffff1660e01b8152600401808373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200182815260200192505050602060405180830381600087803b1580156107da57600080fd5b505af11580156107ee573d6000803e3d6000fd5b505050506040513d602081101561080457600080fd5b810190808051906020019092919050505061081e57600080fd5b505b7f36c177bcb01c6d568244f05261e2946c8c977fa50822f3fa098c470770ee1f3e8685604051808381526020018281526020019250505060405180910390a1505050505050565b60006020528060005260406000206000915090508060000160009054906101000a900460601b908060000160149054906101000a900467ffffffffffffffff169080600001601c9054906101000a900460ff16905083565b600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff16141580156108fc5750600034115b801561094057506000600381111561091057fe5b600080868152602001908152602001600020600001601c9054906101000a900460ff16600381111561093e57fe5b145b61094957600080fd5b60006003843385600034604051602001808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b81526014018573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526014018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401828152602001955050505050506040516020818303038152906040526040518082805190602001908083835b60208310610a6c5780518252602082019150602081019050602083039250610a49565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa158015610aae573d6000803e3d6000fd5b5050506040515160601b90506040518060600160405280826bffffffffffffffffffffffff191681526020018367ffffffffffffffff16815260200160016003811115610af757fe5b81525060008087815260200190815260200160002060008201518160000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908360601c021790555060208201518160000160146101000a81548167ffffffffffffffff021916908367ffffffffffffffff160217905550604082015181600001601c6101000a81548160ff02191690836003811115610b9357fe5b02179055509050507fccc9c05183599bd3135da606eaaf535daffe256e9de33c048014cffcccd4ad57856040518082815260200191505060405180910390a15050505050565b60016003811115610be657fe5b600080878152602001908152602001600020600001601c9054906101000a900460ff166003811115610c1457fe5b14610c1e57600080fd5b600060038233868689604051602001808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b81526014018573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526014018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401828152602001955050505050506040516020818303038152906040526040518082805190602001908083835b60208310610d405780518252602082019150602081019050602083039250610d1d565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa158015610d82573d6000803e3d6000fd5b5050506040515160601b905060008087815260200190815260200160002060000160009054906101000a900460601b6bffffffffffffffffffffffff1916816bffffffffffffffffffffffff1916148015610e10575060008087815260200190815260200160002060000160149054906101000a900467ffffffffffffffff1667ffffffffffffffff164210155b610e1957600080fd5b6003600080888152602001908152602001600020600001601c6101000a81548160ff02191690836003811115610e4b57fe5b0217905550600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff161415610ed1573373ffffffffffffffffffffffffffffffffffffffff166108fc869081150290604051600060405180830381858888f19350505050158015610ecb573d6000803e3d6000fd5b50610fa3565b60008390508073ffffffffffffffffffffffffffffffffffffffff1663a9059cbb33886040518363ffffffff1660e01b8152600401808373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200182815260200192505050602060405180830381600087803b158015610f5d57600080fd5b505af1158015610f71573d6000803e3d6000fd5b505050506040513d6020811015610f8757600080fd5b8101908080519060200190929190505050610fa157600080fd5b505b7f1797d500133f8e427eb9da9523aa4a25cb40f50ebc7dbda3c7c81778973f35ba866040518082815260200191505060405180910390a1505050505050565b600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff161415801561101f5750600085115b801561106357506000600381111561103357fe5b600080888152602001908152602001600020600001601c9054906101000a900460ff16600381111561106157fe5b145b61106c57600080fd5b60006003843385888a604051602001808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b81526014018573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526014018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401828152602001955050505050506040516020818303038152906040526040518082805190602001908083835b6020831061118e578051825260208201915060208101905060208303925061116b565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa1580156111d0573d6000803e3d6000fd5b5050506040515160601b90506040518060600160405280826bffffffffffffffffffffffff191681526020018367ffffffffffffffff1681526020016001600381111561121957fe5b81525060008089815260200190815260200160002060008201518160000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908360601c021790555060208201518160000160146101000a81548167ffffffffffffffff021916908367ffffffffffffffff160217905550604082015181600001601c6101000a81548160ff021916908360038111156112b557fe5b021790555090505060008590508073ffffffffffffffffffffffffffffffffffffffff166323b872dd33308a6040518463ffffffff1660e01b8152600401808473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018281526020019350505050602060405180830381600087803b15801561137d57600080fd5b505af1158015611391573d6000803e3d6000fd5b505050506040513d60208110156113a757600080fd5b81019080805190602001909291905050506113c157600080fd5b7fccc9c05183599bd3135da606eaaf535daffe256e9de33c048014cffcccd4ad57886040518082815260200191505060405180910390a1505050505050505056fea265627a7a723158208c83db436905afce0b7be1012be64818c49323c12d451fe2ab6bce76ff6421c964736f6c63430005110032"; const TAKER_PAYMENT_SPEND_SEARCH_INTERVAL: f64 = 1.; -pub struct QtumDockerOps { - #[allow(dead_code)] - ctx: MmArc, - coin: QtumCoin, -} - -impl CoinDockerOps for QtumDockerOps { - fn rpc_client(&self) -> &UtxoRpcClientEnum { - &self.coin.as_ref().rpc_client - } -} - -impl QtumDockerOps { - pub fn new() -> QtumDockerOps { - let ctx = MmCtxBuilder::new().into_mm_arc(); - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; - let conf = json!({"coin":"QTUM","decimals":8,"network":"regtest","confpath":confpath}); - let req = json!({ - "method": "enable", - }); - let priv_key = Secp256k1Secret::from("809465b17d0a4ddb3e4c69e8f23c2cabad868f51f8bed5c765ad1d6516c3306f"); - let params = UtxoActivationParams::from_legacy_req(&req).unwrap(); - let coin = block_on(qtum_coin_with_priv_key(&ctx, "QTUM", &conf, ¶ms, priv_key)).unwrap(); - QtumDockerOps { ctx, coin } - } - - pub fn initialize_contracts(&self) { - let sender = get_address_by_label(&self.coin, QTUM_ADDRESS_LABEL); - unsafe { - QICK_TOKEN_ADDRESS = Some(self.create_contract(&sender, QRC20_TOKEN_BYTES)); - QORTY_TOKEN_ADDRESS = Some(self.create_contract(&sender, QRC20_TOKEN_BYTES)); - QRC20_SWAP_CONTRACT_ADDRESS = Some(self.create_contract(&sender, QRC20_SWAP_CONTRACT_BYTES)); - } - } - - fn create_contract(&self, sender: &str, hexbytes: &str) -> H160 { - let bytecode = hex::decode(hexbytes).expect("Hex encoded bytes expected"); - let gas_limit = 2_500_000u64; - let gas_price = BigDecimal::from_str("0.0000004").unwrap(); - - match self.coin.as_ref().rpc_client { - UtxoRpcClientEnum::Native(ref native) => { - let result = block_on_f01(native.create_contract(&bytecode.into(), gas_limit, gas_price, sender)) - .expect("!createcontract"); - result.address.0.into() - }, - UtxoRpcClientEnum::Electrum(_) => panic!("Native client expected"), - } - } -} - -pub fn qtum_docker_node(port: u16) -> DockerNode { - let image = GenericImage::new(QTUM_REGTEST_DOCKER_IMAGE, "latest") - .with_env_var("CLIENTS", "2") - .with_env_var("COIN_RPC_PORT", port.to_string()) - .with_env_var("ADDRESS_LABEL", QTUM_ADDRESS_LABEL) - .with_env_var("FILL_MEMPOOL", "true") - .with_wait_for(WaitFor::message_on_stdout("config is ready")); - let image = RunnableImage::from(image).with_mapped_port((port, port)); - let container = image.start().expect("Failed to start Qtum regtest docker container"); - - let name = "qtum"; - let mut conf_path = temp_dir().join("qtum-regtest"); - std::fs::create_dir_all(&conf_path).unwrap(); - conf_path.push(format!("{name}.conf")); - Command::new("docker") - .arg("cp") - .arg(format!("{}:/data/node_0/{}.conf", container.id(), name)) - .arg(&conf_path) - .status() - .expect("Failed to execute docker command"); - let timeout = wait_until_ms(3000); - loop { - if conf_path.exists() { - break; - }; - assert!(now_ms() < timeout, "Test timed out"); - } - - unsafe { QTUM_CONF_PATH = Some(conf_path) }; - DockerNode { - container, - ticker: name.to_owned(), - port, - } -} - fn withdraw_and_send(mm: &MarketMakerIt, coin: &str, to: &str, amount: f64) { let withdraw = block_on(mm.rpc(&json! ({ "mmrpc": "2.0", @@ -553,7 +464,7 @@ fn test_search_for_swap_tx_spend_taker_spent() { let maker_payment_args = SendPaymentArgs { time_lock_duration: 0, time_lock: timelock, - other_pubkey: taker_pub, + other_pubkey: &taker_pub, secret_hash: secret_hash.as_slice(), amount, swap_contract_address: &maker_coin.swap_contract_address(), @@ -582,7 +493,7 @@ fn test_search_for_swap_tx_spend_taker_spent() { let taker_spends_payment_args = SpendPaymentArgs { other_payment_tx: &payment_tx_hex, time_lock: timelock, - other_pubkey: maker_pub, + other_pubkey: &maker_pub, secret, secret_hash: secret_hash.as_slice(), swap_contract_address: &taker_coin.swap_contract_address(), @@ -606,13 +517,12 @@ fn test_search_for_swap_tx_spend_taker_spent() { let search_input = SearchForSwapTxSpendInput { time_lock: timelock, - other_pub: taker_pub, + other_pub: &taker_pub, secret_hash: secret_hash.as_slice(), tx: &payment_tx_hex, search_from_block, swap_contract_address: &maker_coin.swap_contract_address(), swap_unique_data: &[], - watcher_reward: false, }; let actual = block_on(maker_coin.search_for_swap_tx_spend_my(search_input)); let expected = Ok(Some(FoundSwapTxSpend::Spent(spend))); @@ -692,7 +602,6 @@ fn test_search_for_swap_tx_spend_maker_refunded() { search_from_block, swap_contract_address: &maker_coin.swap_contract_address(), swap_unique_data: &[], - watcher_reward: false, }; let actual = block_on(maker_coin.search_for_swap_tx_spend_my(search_input)); let expected = Ok(Some(FoundSwapTxSpend::Refunded(refund))); @@ -747,7 +656,6 @@ fn test_search_for_swap_tx_spend_not_spent() { search_from_block, swap_contract_address: &maker_coin.swap_contract_address(), swap_unique_data: &[], - watcher_reward: false, }; let actual = block_on(maker_coin.search_for_swap_tx_spend_my(search_input)); // maker payment hasn't been spent or refunded yet @@ -769,7 +677,7 @@ fn test_wait_for_tx_spend() { let maker_payment_args = SendPaymentArgs { time_lock_duration: 0, time_lock: timelock, - other_pubkey: taker_pub, + other_pubkey: &taker_pub, secret_hash: secret_hash.as_slice(), amount, swap_contract_address: &maker_coin.swap_contract_address(), @@ -874,8 +782,8 @@ fn test_check_balance_on_order_post_base_coin_locked() { let my_address = coin.my_address().expect("!my_address"); fill_address(&coin, &my_address, 10.into(), timeout); - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; - let qick_contract_address = format!("{:#02x}", unsafe { QICK_TOKEN_ADDRESS.expect("!QICK_TOKEN_ADDRESS") }); + let confpath = qtum_conf_path(); + let qick_contract_address = format!("{:#02x}", qick_token_address()); let coins = json!([ {"coin":"MYCOIN","asset":"MYCOIN","required_confirmations":0,"txversion":4,"overwintered":1,"txfee":1000,"protocol":{"type":"UTXO"}}, {"coin":"QICK","required_confirmations":1,"pubtype": 120,"p2shtype": 50,"wiftype": 128,"mm2": 1,"mature_confirmations": 500,"confpath": confpath,"network":"regtest", @@ -978,7 +886,7 @@ fn test_check_balance_on_order_post_base_coin_locked() { /// /// Please note this function should be called before the Qtum balance is filled. fn test_get_max_taker_vol_and_trade_with_dynamic_trade_fee(coin: QtumCoin, priv_key: &[u8]) { - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; + let confpath = qtum_conf_path(); let coins = json! ([ {"coin":"MYCOIN","asset":"MYCOIN","txversion":4,"overwintered":1,"txfee":1000,"protocol":{"type":"UTXO"}}, {"coin":"QTUM","decimals":8,"pubtype":120,"p2shtype":110,"wiftype":128,"txfee":0,"txfee_volatility_percent":0.1, @@ -1092,10 +1000,13 @@ fn test_get_max_taker_vol_and_trade_with_dynamic_trade_fee(coin: QtumCoin, priv_ let _taker_payment_tx = block_on(coin.send_taker_payment(taker_payment_args)).expect("!send_taker_payment"); let my_balance = block_on_f01(coin.my_spendable_balance()).expect("!my_balance"); - assert_eq!( - my_balance, - BigDecimal::from(0u32), - "NOT AN ERROR, but it would be better if the balance remained zero" + // Tolerance increased to accommodate 2% dex fee rate (larger fee amount affects gas estimation gaps) + let tolerance = BigDecimal::from_str("0.002").unwrap(); + assert!( + my_balance < tolerance, + "NOT AN ERROR, but it would be better if the balance remained near zero. \ + Due to dynamic fee calculation precision, a small dust amount ({}) may remain.", + my_balance ); } @@ -1152,8 +1063,8 @@ fn test_trade_preimage_not_sufficient_base_coin_balance_for_ticker() { let qtum_balance = MmNumber::from("0.005").to_decimal(); let (_, _, priv_key) = generate_qrc20_coin_with_random_privkey("QICK", qtum_balance.clone(), qick_balance); - let qick_contract_address = format!("{:#02x}", unsafe { QICK_TOKEN_ADDRESS.expect("!QICK_TOKEN_ADDRESS") }); - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; + let qick_contract_address = format!("{:#02x}", qick_token_address()); + let confpath = qtum_conf_path(); let coins = json! ([ {"coin":"MYCOIN","asset":"MYCOIN","txversion":4,"overwintered":1,"txfee":1000,"protocol":{"type":"UTXO"}}, {"coin":"QICK","required_confirmations":1,"pubtype": 120,"p2shtype": 50,"wiftype": 128,"mm2": 1,"mature_confirmations": 500,"confpath": confpath,"network":"regtest", @@ -1213,7 +1124,7 @@ fn test_trade_preimage_dynamic_fee_not_sufficient_balance() { let qtum_balance = MmNumber::from("0.5").to_decimal(); let (_ctx, _coin, priv_key) = generate_qtum_coin_with_random_privkey("QTUM", qtum_balance.clone(), Some(0)); - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; + let confpath = qtum_conf_path(); let coins = json! ([ {"coin":"MYCOIN","asset":"MYCOIN","txversion":4,"overwintered":1,"txfee":1000,"protocol":{"type":"UTXO"}}, {"coin":"QTUM","decimals":8,"pubtype":120,"p2shtype":110,"wiftype":128,"txfee":0,"txfee_volatility_percent":0.1, @@ -1275,7 +1186,7 @@ fn test_trade_preimage_deduct_fee_from_output_failed() { let qtum_balance = MmNumber::from("0.00073").to_decimal(); let (_ctx, _coin, priv_key) = generate_qtum_coin_with_random_privkey("QTUM", qtum_balance.clone(), Some(0)); - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; + let confpath = qtum_conf_path(); let coins = json! ([ {"coin":"MYCOIN","asset":"MYCOIN","txversion":4,"overwintered":1,"txfee":1000,"protocol":{"type":"UTXO"}}, {"coin":"QTUM","decimals":8,"pubtype":120,"p2shtype":110,"wiftype":128,"txfee":0,"txfee_volatility_percent":0.1, @@ -1336,7 +1247,7 @@ fn test_segwit_native_balance() { let (_ctx, _coin, priv_key) = generate_segwit_qtum_coin_with_random_privkey("QTUM", BigDecimal::try_from(0.5).unwrap(), Some(0)); - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; + let confpath = qtum_conf_path(); let coins = json! ([ {"coin":"QTUM","decimals":8,"pubtype":120,"p2shtype":110,"wiftype":128,"segwit":true,"txfee":0,"txfee_volatility_percent":0.1, "mm2":1,"mature_confirmations":500,"network":"regtest","confpath":confpath,"protocol":{"type":"UTXO"},"bech32_hrp":"qcrt","address_format":{"format":"segwit"}}, @@ -1383,7 +1294,7 @@ fn test_withdraw_and_send_from_segwit() { let (_ctx, _coin, priv_key) = generate_segwit_qtum_coin_with_random_privkey("QTUM", BigDecimal::try_from(0.7).unwrap(), Some(0)); - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; + let confpath = qtum_conf_path(); let coins = json! ([ {"coin":"QTUM","decimals":8,"pubtype":120,"p2shtype":110,"wiftype":128,"segwit":true,"txfee":0,"txfee_volatility_percent":0.1, "mm2":1,"mature_confirmations":500,"network":"regtest","confpath":confpath,"protocol":{"type":"UTXO"},"bech32_hrp":"qcrt","address_format":{"format":"segwit"}}, @@ -1432,7 +1343,7 @@ fn test_withdraw_and_send_legacy_to_segwit() { let (_ctx, _coin, priv_key) = generate_qtum_coin_with_random_privkey("QTUM", BigDecimal::try_from(0.7).unwrap(), Some(0)); - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; + let confpath = qtum_conf_path(); let coins = json! ([ {"coin":"QTUM","decimals":8,"pubtype":120,"p2shtype":110,"wiftype":128,"segwit":true,"txfee":0,"txfee_volatility_percent":0.1, "mm2":1,"mature_confirmations":500,"network":"regtest","confpath":confpath,"protocol":{"type":"UTXO"},"bech32_hrp":"qcrt"}, @@ -1482,7 +1393,7 @@ fn test_search_for_segwit_swap_tx_spend_native_was_refunded_maker() { let maker_payment = SendPaymentArgs { time_lock_duration: 0, time_lock, - other_pubkey: my_public_key, + other_pubkey: &my_public_key, secret_hash: &[0; 20], amount: 1u64.into(), swap_contract_address: &None, @@ -1504,7 +1415,7 @@ fn test_search_for_segwit_swap_tx_spend_native_was_refunded_maker() { let maker_refunds_payment_args = RefundPaymentArgs { payment_tx: &tx.tx_hex(), time_lock, - other_pubkey: my_public_key, + other_pubkey: &my_public_key, tx_type_with_secret_hash: SwapTxTypeWithSecretHash::TakerOrMakerPayment { maker_secret_hash: &[0; 20], }, @@ -1523,15 +1434,15 @@ fn test_search_for_segwit_swap_tx_spend_native_was_refunded_maker() { }; block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + let pubkey = coin.my_public_key().unwrap(); let search_input = SearchForSwapTxSpendInput { time_lock, - other_pub: coin.my_public_key().unwrap(), + other_pub: &pubkey, secret_hash: &[0; 20], tx: &tx.tx_hex(), search_from_block: 0, swap_contract_address: &None, swap_unique_data: &[], - watcher_reward: false, }; let found = block_on(coin.search_for_swap_tx_spend_my(search_input)) .unwrap() @@ -1550,7 +1461,7 @@ fn test_search_for_segwit_swap_tx_spend_native_was_refunded_taker() { let taker_payment = SendPaymentArgs { time_lock_duration: 0, time_lock, - other_pubkey: my_public_key, + other_pubkey: &my_public_key, secret_hash: &[0; 20], amount: 1u64.into(), swap_contract_address: &None, @@ -1572,7 +1483,7 @@ fn test_search_for_segwit_swap_tx_spend_native_was_refunded_taker() { let maker_refunds_payment_args = RefundPaymentArgs { payment_tx: &tx.tx_hex(), time_lock, - other_pubkey: my_public_key, + other_pubkey: &my_public_key, tx_type_with_secret_hash: SwapTxTypeWithSecretHash::TakerOrMakerPayment { maker_secret_hash: &[0; 20], }, @@ -1591,15 +1502,15 @@ fn test_search_for_segwit_swap_tx_spend_native_was_refunded_taker() { }; block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + let pubkey = coin.my_public_key().unwrap(); let search_input = SearchForSwapTxSpendInput { time_lock, - other_pub: coin.my_public_key().unwrap(), + other_pub: &pubkey, secret_hash: &[0; 20], tx: &tx.tx_hex(), search_from_block: 0, swap_contract_address: &None, swap_unique_data: &[], - watcher_reward: false, }; let found = block_on(coin.search_for_swap_tx_spend_my(search_input)) .unwrap() @@ -1632,7 +1543,7 @@ fn segwit_address_in_the_orderbook() { let (_ctx, coin, priv_key) = generate_qtum_coin_with_random_privkey("QTUM", BigDecimal::try_from(0.5).unwrap(), Some(0)); - let confpath = unsafe { QTUM_CONF_PATH.as_ref().expect("Qtum config is not set yet") }; + let confpath = qtum_conf_path(); let coins = json! ([ {"coin":"QTUM","decimals":8,"pubtype":120,"p2shtype":110,"wiftype":128,"segwit":true,"txfee":0,"txfee_volatility_percent":0.1, "mm2":1,"mature_confirmations":500,"network":"regtest","confpath":confpath,"protocol":{"type":"UTXO"},"bech32_hrp":"qcrt"}, @@ -1727,9 +1638,10 @@ fn test_send_standard_taker_fee_qtum() { let tx = block_on(coin.send_taker_fee(DexFee::Standard(amount.clone().into()), &[], 0)).expect("!send_taker_fee"); assert!(matches!(tx, TransactionEnum::UtxoTx(_)), "Expected UtxoTx"); + let pubkey = coin.my_public_key().unwrap(); block_on(coin.validate_fee(ValidateFeeArgs { fee_tx: &tx, - expected_sender: coin.my_public_key().unwrap(), + expected_sender: &pubkey, dex_fee: &DexFee::Standard(amount.into()), min_block_number: 0, uuid: &[], @@ -1757,9 +1669,10 @@ fn test_send_taker_fee_with_burn_qtum() { .expect("!send_taker_fee"); assert!(matches!(tx, TransactionEnum::UtxoTx(_)), "Expected UtxoTx"); + let pubkey = coin.my_public_key().unwrap(); block_on(coin.validate_fee(ValidateFeeArgs { fee_tx: &tx, - expected_sender: coin.my_public_key().unwrap(), + expected_sender: &pubkey, dex_fee: &DexFee::WithBurn { fee_amount: fee_amount.into(), burn_amount: burn_amount.into(), @@ -1783,9 +1696,10 @@ fn test_send_taker_fee_qrc20() { let tx = block_on(coin.send_taker_fee(DexFee::Standard(amount.clone().into()), &[], 0)).expect("!send_taker_fee"); assert!(matches!(tx, TransactionEnum::UtxoTx(_)), "Expected UtxoTx"); + let pubkey = coin.my_public_key().unwrap(); block_on(coin.validate_fee(ValidateFeeArgs { fee_tx: &tx, - expected_sender: coin.my_public_key().unwrap(), + expected_sender: &pubkey, dex_fee: &DexFee::Standard(amount.into()), min_block_number: 0, uuid: &[], diff --git a/mm2src/mm2_main/tests/docker_tests/runner/geth.rs b/mm2src/mm2_main/tests/docker_tests/runner/geth.rs new file mode 100644 index 0000000000..a87041d554 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/runner/geth.rs @@ -0,0 +1,36 @@ +//! Geth/ETH setup for docker tests. + +use super::{DockerTestMode, DockerTestRunner}; + +use crate::docker_tests::helpers::eth::{ + erc20_contract, geth_account, geth_docker_node, geth_erc1155_contract, geth_erc721_contract, geth_maker_swap_v2, + geth_nft_maker_swap_v2, geth_taker_swap_v2, geth_usdt_contract, init_geth_node, swap_contract, + wait_for_geth_node_ready, watchers_swap_contract, +}; + +pub(super) fn setup(runner: &mut DockerTestRunner) { + match runner.config.mode { + DockerTestMode::Testcontainers => { + let node = geth_docker_node("ETH", 8545); + wait_for_geth_node_ready(); + init_geth_node(); + runner.hold(node); + }, + DockerTestMode::ComposeInit => { + wait_for_geth_node_ready(); + init_geth_node(); + }, + } + + // Ensure globals are initialized for test helpers. + let _ = geth_account(); + let _ = erc20_contract(); + let _ = swap_contract(); + let _ = geth_maker_swap_v2(); + let _ = geth_taker_swap_v2(); + let _ = watchers_swap_contract(); + let _ = geth_erc721_contract(); + let _ = geth_erc1155_contract(); + let _ = geth_nft_maker_swap_v2(); + let _ = geth_usdt_contract(); +} diff --git a/mm2src/mm2_main/tests/docker_tests/runner/mod.rs b/mm2src/mm2_main/tests/docker_tests/runner/mod.rs new file mode 100644 index 0000000000..965d75249a --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/runner/mod.rs @@ -0,0 +1,288 @@ +//! Docker tests custom runner (split from the old monolithic `runner.rs`). +//! +//! Public API is preserved: +//! - `docker_tests::runner::docker_tests_runner_impl()` +//! +//! Internals are split into per-chain setup submodules to push cfg gates to module boundaries. + +use std::any::Any; +use std::env; +use std::io::{BufRead, BufReader}; +use std::process::Command; +use test::{test_main, StaticBenchFn, StaticTestFn, TestDescAndFn}; + +// ============================================================================= +// Per-chain setup submodules +// ============================================================================= + +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-sia", + feature = "docker-tests-integration" +))] +mod utxo; + +#[cfg(any(feature = "docker-tests-slp", feature = "docker-tests-integration"))] +mod slp; + +#[cfg(feature = "docker-tests-qrc20")] +mod qtum; + +#[cfg(any( + feature = "docker-tests-eth", + feature = "docker-tests-watchers-eth", + feature = "docker-tests-integration" +))] +mod geth; + +#[cfg(feature = "docker-tests-zcoin")] +mod zcoin; + +#[cfg(any(feature = "docker-tests-tendermint", feature = "docker-tests-integration"))] +mod tendermint; + +#[cfg(feature = "docker-tests-sia")] +mod sia; + +// ============================================================================= +// Core runner types +// ============================================================================= + +/// Execution mode for docker tests. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum DockerTestMode { + /// Default: Start containers via testcontainers, run initialization. + Testcontainers, + /// Docker-compose mode: Containers already running, run initialization. + ComposeInit, +} + +/// Environment variable to indicate docker-compose mode (containers already running). +const ENV_DOCKER_COMPOSE_MODE: &str = "KDF_DOCKER_COMPOSE_ENV"; + +/// Determine which execution mode to use based on environment variables. +fn determine_test_mode() -> DockerTestMode { + if env::var(ENV_DOCKER_COMPOSE_MODE).is_ok() { + DockerTestMode::ComposeInit + } else { + DockerTestMode::Testcontainers + } +} + +/// Parses runner config from env once. +pub(crate) struct DockerTestConfig { + pub(crate) mode: DockerTestMode, + /// When `_MM2_TEST_CONF` is set, the runner must skip docker setup entirely. + pub(crate) skip_setup: bool, +} + +impl DockerTestConfig { + fn from_env() -> Self { + DockerTestConfig { + mode: determine_test_mode(), + skip_setup: env::var("_MM2_TEST_CONF").is_ok(), + } + } +} + +/// Stateful docker test runner holding container keep-alives. +/// +/// Keep-alives are stored as `Box` to ensure RAII drop only happens +/// after `test_main` returns. +pub(crate) struct DockerTestRunner { + pub(crate) config: DockerTestConfig, + pub(crate) keep_alive: Vec>, +} + +impl DockerTestRunner { + fn new(config: DockerTestConfig) -> Self { + DockerTestRunner { + config, + keep_alive: Vec::new(), + } + } + + pub(crate) fn hold(&mut self, container: T) { + self.keep_alive.push(Box::new(container)); + } + + pub(crate) fn is_testcontainers(&self) -> bool { + self.config.mode == DockerTestMode::Testcontainers + } + + fn setup_or_reuse_nodes(&mut self) { + if self.is_testcontainers() { + for image in required_images() { + pull_docker_image(image); + remove_docker_containers(image); + } + } + + #[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-sia", + feature = "docker-tests-integration" + ))] + utxo::setup(self); + + #[cfg(feature = "docker-tests-qrc20")] + qtum::setup(self); + + #[cfg(any(feature = "docker-tests-slp", feature = "docker-tests-integration"))] + slp::setup(self); + + #[cfg(any( + feature = "docker-tests-eth", + feature = "docker-tests-watchers-eth", + feature = "docker-tests-integration" + ))] + geth::setup(self); + + #[cfg(feature = "docker-tests-zcoin")] + zcoin::setup(self); + + #[cfg(any(feature = "docker-tests-tendermint", feature = "docker-tests-integration"))] + tendermint::setup(self); + + #[cfg(feature = "docker-tests-sia")] + sia::setup(self); + } + + fn run_tests(&mut self, tests: &[&TestDescAndFn]) { + let owned_tests: Vec<_> = tests + .iter() + .map(|t| match t.testfn { + StaticTestFn(f) => TestDescAndFn { + testfn: StaticTestFn(f), + desc: t.desc.clone(), + }, + StaticBenchFn(f) => TestDescAndFn { + testfn: StaticBenchFn(f), + desc: t.desc.clone(), + }, + _ => panic!("non-static tests passed to lp_coins test runner"), + }) + .collect(); + + let args: Vec = env::args().collect(); + test_main(&args, owned_tests, None); + } +} + +/// Public API: custom test runner implementation called by `docker_tests_main.rs`. +pub fn docker_tests_runner_impl(tests: &[&TestDescAndFn]) { + let config = DockerTestConfig::from_env(); + log!("Docker test mode: {:?}", config.mode); + + let mut runner = DockerTestRunner::new(config); + + if !runner.config.skip_setup { + runner.setup_or_reuse_nodes(); + } + + runner.run_tests(tests); +} + +// ============================================================================= +// Images + docker utility functions +// ============================================================================= + +fn required_images() -> Vec<&'static str> { + let mut images = Vec::new(); + + #[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-sia", + feature = "docker-tests-integration" + ))] + { + use crate::docker_tests::helpers::utxo::UTXO_ASSET_DOCKER_IMAGE_WITH_TAG; + images.push(UTXO_ASSET_DOCKER_IMAGE_WITH_TAG); + } + + #[cfg(any(feature = "docker-tests-slp", feature = "docker-tests-integration"))] + { + use crate::docker_tests::helpers::slp::FORSLP_IMAGE_WITH_TAG; + images.push(FORSLP_IMAGE_WITH_TAG); + } + + #[cfg(feature = "docker-tests-qrc20")] + { + use crate::docker_tests::helpers::qrc20::QTUM_REGTEST_DOCKER_IMAGE_WITH_TAG; + images.push(QTUM_REGTEST_DOCKER_IMAGE_WITH_TAG); + } + + #[cfg(any( + feature = "docker-tests-eth", + feature = "docker-tests-watchers-eth", + feature = "docker-tests-integration" + ))] + { + use crate::docker_tests::helpers::eth::GETH_DOCKER_IMAGE_WITH_TAG; + images.push(GETH_DOCKER_IMAGE_WITH_TAG); + } + + #[cfg(any(feature = "docker-tests-tendermint", feature = "docker-tests-integration"))] + { + use crate::docker_tests::helpers::tendermint::{ + ATOM_IMAGE_WITH_TAG, IBC_RELAYER_IMAGE_WITH_TAG, NUCLEUS_IMAGE, + }; + images.push(NUCLEUS_IMAGE); + images.push(ATOM_IMAGE_WITH_TAG); + images.push(IBC_RELAYER_IMAGE_WITH_TAG); + } + + #[cfg(feature = "docker-tests-zcoin")] + { + use crate::docker_tests::helpers::zcoin::ZOMBIE_ASSET_DOCKER_IMAGE_WITH_TAG; + images.push(ZOMBIE_ASSET_DOCKER_IMAGE_WITH_TAG); + } + + #[cfg(feature = "docker-tests-sia")] + { + use crate::docker_tests::helpers::sia::SIA_DOCKER_IMAGE_WITH_TAG; + images.push(SIA_DOCKER_IMAGE_WITH_TAG); + } + + images.sort_unstable(); + images.dedup(); + images +} + +fn pull_docker_image(name: &str) { + Command::new("docker") + .arg("pull") + .arg(name) + .status() + .expect("Failed to execute docker command"); +} + +fn remove_docker_containers(name: &str) { + let stdout = Command::new("docker") + .arg("ps") + .arg("-f") + .arg(format!("ancestor={name}")) + .arg("-q") + .output() + .expect("Failed to execute docker command"); + + let reader = BufReader::new(stdout.stdout.as_slice()); + let ids: Vec<_> = reader.lines().map(|line| line.unwrap()).collect(); + if !ids.is_empty() { + Command::new("docker") + .arg("rm") + .arg("-f") + .args(ids) + .status() + .expect("Failed to execute docker command"); + } +} diff --git a/mm2src/mm2_main/tests/docker_tests/runner/qtum.rs b/mm2src/mm2_main/tests/docker_tests/runner/qtum.rs new file mode 100644 index 0000000000..5bc70cf131 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/runner/qtum.rs @@ -0,0 +1,32 @@ +//! Qtum/QRC20 setup for docker tests. + +use super::{DockerTestMode, DockerTestRunner}; + +use crate::docker_tests::helpers::qrc20::{ + qick_token_address, qorty_token_address, qrc20_swap_contract_address, qtum_conf_path, qtum_docker_node, + setup_qtum_conf_for_compose, QtumDockerOps, +}; + +pub(super) fn setup(runner: &mut DockerTestRunner) { + match runner.config.mode { + DockerTestMode::Testcontainers => { + let node = qtum_docker_node(9000); + let qtum_ops = QtumDockerOps::new(); + crate::docker_tests::helpers::docker_ops::CoinDockerOps::wait_ready(&qtum_ops, 2); + qtum_ops.initialize_contracts(); + runner.hold(node); + }, + DockerTestMode::ComposeInit => { + setup_qtum_conf_for_compose(); + let qtum_ops = QtumDockerOps::new(); + crate::docker_tests::helpers::docker_ops::CoinDockerOps::wait_ready(&qtum_ops, 2); + qtum_ops.initialize_contracts(); + }, + } + + // Ensure globals are initialized for test helpers. + let _ = qtum_conf_path().clone(); + let _ = qick_token_address(); + let _ = qorty_token_address(); + let _ = qrc20_swap_contract_address(); +} diff --git a/mm2src/mm2_main/tests/docker_tests/runner/sia.rs b/mm2src/mm2_main/tests/docker_tests/runner/sia.rs new file mode 100644 index 0000000000..e6994e1ed3 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/runner/sia.rs @@ -0,0 +1,21 @@ +//! Sia setup for docker tests. + +use super::{DockerTestMode, DockerTestRunner}; + +use common::block_on; + +use crate::docker_tests::helpers::sia::sia_docker_node; +use crate::sia_tests::utils::wait_for_dsia_node_ready; + +pub(super) fn setup(runner: &mut DockerTestRunner) { + match runner.config.mode { + DockerTestMode::Testcontainers => { + let node = sia_docker_node("SIA", 9980); + block_on(wait_for_dsia_node_ready()); + runner.hold(node); + }, + DockerTestMode::ComposeInit => { + block_on(wait_for_dsia_node_ready()); + }, + } +} diff --git a/mm2src/mm2_main/tests/docker_tests/runner/slp.rs b/mm2src/mm2_main/tests/docker_tests/runner/slp.rs new file mode 100644 index 0000000000..3d0c9a4cce --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/runner/slp.rs @@ -0,0 +1,25 @@ +//! SLP/BCH (FORSLP) setup for docker tests. + +use super::{DockerTestMode, DockerTestRunner}; + +use crate::docker_tests::helpers::docker_ops::setup_utxo_conf_for_compose; +use crate::docker_tests::helpers::env::KDF_FORSLP_SERVICE; +use crate::docker_tests::helpers::slp::{forslp_docker_node, BchDockerOps}; + +pub(super) fn setup(runner: &mut DockerTestRunner) { + match runner.config.mode { + DockerTestMode::Testcontainers => { + let node = forslp_docker_node(10000); + let for_slp_ops = BchDockerOps::from_ticker("FORSLP"); + crate::docker_tests::helpers::docker_ops::CoinDockerOps::wait_ready(&for_slp_ops, 4); + for_slp_ops.initialize_slp(); + runner.hold(node); + }, + DockerTestMode::ComposeInit => { + setup_utxo_conf_for_compose("FORSLP", KDF_FORSLP_SERVICE); + let for_slp_ops = BchDockerOps::from_ticker("FORSLP"); + crate::docker_tests::helpers::docker_ops::CoinDockerOps::wait_ready(&for_slp_ops, 4); + for_slp_ops.initialize_slp(); + }, + } +} diff --git a/mm2src/mm2_main/tests/docker_tests/runner/tendermint.rs b/mm2src/mm2_main/tests/docker_tests/runner/tendermint.rs new file mode 100644 index 0000000000..e7925eab9f --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/runner/tendermint.rs @@ -0,0 +1,71 @@ +//! Tendermint/Cosmos/IBC setup for docker tests. + +use super::{DockerTestMode, DockerTestRunner}; + +use crate::docker_tests::helpers::tendermint::{ + atom_node, ibc_relayer_node, nucleus_node, prepare_ibc_channels, prepare_ibc_channels_compose, + wait_until_relayer_container_is_ready, wait_until_relayer_container_is_ready_compose, +}; + +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +pub(super) fn setup(runner: &mut DockerTestRunner) { + match runner.config.mode { + DockerTestMode::Testcontainers => { + let runtime_dir = prepare_runtime_dir().unwrap(); + + let nucleus_node_instance = nucleus_node(runtime_dir.clone()); + let atom_node_instance = atom_node(runtime_dir.clone()); + let ibc_relayer_node_instance = ibc_relayer_node(runtime_dir.clone()); + + prepare_ibc_channels(ibc_relayer_node_instance.container.id()); + thread::sleep(Duration::from_secs(10)); + wait_until_relayer_container_is_ready(ibc_relayer_node_instance.container.id()); + + runner.hold(nucleus_node_instance); + runner.hold(atom_node_instance); + runner.hold(ibc_relayer_node_instance); + }, + DockerTestMode::ComposeInit => { + let _runtime_dir = get_runtime_dir(); + + prepare_ibc_channels_compose(); + thread::sleep(Duration::from_secs(10)); + wait_until_relayer_container_is_ready_compose(); + }, + } +} + +/// Get the runtime directory path. +fn get_runtime_dir() -> PathBuf { + let project_root = { + let mut current_dir = std::env::current_dir().unwrap(); + current_dir.pop(); + current_dir.pop(); + current_dir + }; + project_root.join(".docker/container-runtime") +} + +fn prepare_runtime_dir() -> std::io::Result { + let project_root = { + let mut current_dir = std::env::current_dir().unwrap(); + current_dir.pop(); + current_dir.pop(); + current_dir + }; + + let containers_state_dir = project_root.join(".docker/container-state"); + assert!(containers_state_dir.exists()); + let containers_runtime_dir = project_root.join(".docker/container-runtime"); + + if containers_runtime_dir.exists() { + std::fs::remove_dir_all(&containers_runtime_dir).unwrap(); + } + + mm2_io::fs::copy_dir_all(&containers_state_dir, &containers_runtime_dir).unwrap(); + + Ok(containers_runtime_dir) +} diff --git a/mm2src/mm2_main/tests/docker_tests/runner/utxo.rs b/mm2src/mm2_main/tests/docker_tests/runner/utxo.rs new file mode 100644 index 0000000000..fb1742b501 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/runner/utxo.rs @@ -0,0 +1,57 @@ +//! UTXO (MYCOIN, MYCOIN1) setup for docker tests. + +use super::{DockerTestMode, DockerTestRunner}; + +use crate::docker_tests::helpers::docker_ops::setup_utxo_conf_for_compose; +use crate::docker_tests::helpers::env::KDF_MYCOIN_SERVICE; +use crate::docker_tests::helpers::utxo::{utxo_asset_docker_node, UtxoAssetDockerOps}; + +#[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-integration" +))] +use crate::docker_tests::helpers::env::KDF_MYCOIN1_SERVICE; + +pub(super) fn setup(runner: &mut DockerTestRunner) { + // MYCOIN + match runner.config.mode { + DockerTestMode::Testcontainers => { + let node = utxo_asset_docker_node("MYCOIN", 8000); + let utxo_ops = UtxoAssetDockerOps::from_ticker("MYCOIN"); + crate::docker_tests::helpers::docker_ops::CoinDockerOps::wait_ready(&utxo_ops, 4); + runner.hold(node); + }, + DockerTestMode::ComposeInit => { + setup_utxo_conf_for_compose("MYCOIN", KDF_MYCOIN_SERVICE); + let utxo_ops = UtxoAssetDockerOps::from_ticker("MYCOIN"); + crate::docker_tests::helpers::docker_ops::CoinDockerOps::wait_ready(&utxo_ops, 4); + }, + } + + // MYCOIN1 (only for utxo pair tests - not needed by Sia) + #[cfg(any( + feature = "docker-tests-swaps", + feature = "docker-tests-ordermatch", + feature = "docker-tests-watchers", + feature = "docker-tests-qrc20", + feature = "docker-tests-integration" + ))] + { + match runner.config.mode { + DockerTestMode::Testcontainers => { + let node = utxo_asset_docker_node("MYCOIN1", 8001); + let utxo_ops1 = UtxoAssetDockerOps::from_ticker("MYCOIN1"); + crate::docker_tests::helpers::docker_ops::CoinDockerOps::wait_ready(&utxo_ops1, 4); + runner.hold(node); + }, + DockerTestMode::ComposeInit => { + setup_utxo_conf_for_compose("MYCOIN1", KDF_MYCOIN1_SERVICE); + let utxo_ops1 = UtxoAssetDockerOps::from_ticker("MYCOIN1"); + crate::docker_tests::helpers::docker_ops::CoinDockerOps::wait_ready(&utxo_ops1, 4); + }, + } + } +} diff --git a/mm2src/mm2_main/tests/docker_tests/runner/zcoin.rs b/mm2src/mm2_main/tests/docker_tests/runner/zcoin.rs new file mode 100644 index 0000000000..e5ba30ae33 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/runner/zcoin.rs @@ -0,0 +1,23 @@ +//! ZCoin/Zombie setup for docker tests. + +use super::{DockerTestMode, DockerTestRunner}; + +use crate::docker_tests::helpers::docker_ops::setup_utxo_conf_for_compose; +use crate::docker_tests::helpers::env::KDF_ZOMBIE_SERVICE; +use crate::docker_tests::helpers::zcoin::{zombie_asset_docker_node, ZCoinAssetDockerOps}; + +pub(super) fn setup(runner: &mut DockerTestRunner) { + match runner.config.mode { + DockerTestMode::Testcontainers => { + let node = zombie_asset_docker_node(7090); + let zombie_ops = ZCoinAssetDockerOps::new(); + crate::docker_tests::helpers::docker_ops::CoinDockerOps::wait_ready(&zombie_ops, 4); + runner.hold(node); + }, + DockerTestMode::ComposeInit => { + setup_utxo_conf_for_compose("ZOMBIE", KDF_ZOMBIE_SERVICE); + let zombie_ops = ZCoinAssetDockerOps::new(); + crate::docker_tests::helpers::docker_ops::CoinDockerOps::wait_ready(&zombie_ops, 4); + }, + } +} diff --git a/mm2src/mm2_main/tests/docker_tests/sia_docker_tests.rs b/mm2src/mm2_main/tests/docker_tests/sia_docker_tests.rs index 2770e58a92..c7aa94de56 100644 --- a/mm2src/mm2_main/tests/docker_tests/sia_docker_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/sia_docker_tests.rs @@ -59,8 +59,7 @@ fn test_sia_client_consensus_tip() { let _response = block_on(api_client.dispatcher(ConsensusTipRequest)).unwrap(); } -// This test likely needs to be removed because mine_blocks has possibility of interfering with other async tests -// related to block height +// Test that mining to an address results in visible balance. #[test] fn test_sia_client_address_balance() { let conf = SiaHttpConf { @@ -74,13 +73,24 @@ fn test_sia_client_address_balance() { Address::from_str("591fcf237f8854b5653d1ac84ae4c107b37f148c3c7b413f292d48db0c25a8840be0653e411f").unwrap(); block_on(api_client.mine_blocks(10, &address)).unwrap(); - let request = AddressBalanceRequest { address }; - let response = block_on(api_client.dispatcher(request)).unwrap(); - - // It's hard to predict how much was mined to this address while other tests are also mining in the same network. - // Looks like the halving happens so quickly and the sum of mined coins change between different test runs. - // Just make sure we at least mined something. - assert!(response.immature_siacoins + response.siacoins > Currency(0)); + // Poll for the address indexer to process the new blocks. + // walletd's debug/mine endpoint returns after blocks are applied to consensus, + // but address balance indexing happens asynchronously in a background goroutine + // (wallet/manager.go syncStore). So we must retry until the balance becomes visible. + let start = std::time::Instant::now(); + loop { + let request = AddressBalanceRequest { + address: address.clone(), + }; + let response = block_on(api_client.dispatcher(request)).unwrap(); + if response.immature_siacoins + response.siacoins > Currency(0) { + break; + } + if start.elapsed() > std::time::Duration::from_secs(5) { + panic!("Timed out waiting for address balance to become non-zero after mining 10 blocks"); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } } #[test] diff --git a/mm2src/mm2_main/tests/docker_tests/slp_tests.rs b/mm2src/mm2_main/tests/docker_tests/slp_tests.rs index 4bc7223bae..7fd9c7930f 100644 --- a/mm2src/mm2_main/tests/docker_tests/slp_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/slp_tests.rs @@ -1,16 +1,100 @@ -use crate::docker_tests::docker_tests_common::*; +use crate::docker_tests::helpers::slp::{get_prefilled_slp_privkey, get_slp_token_id}; use crate::integration_tests_common::enable_native; +use bitcrypto::ChecksumType; +use coins::utxo::UtxoAddressFormat; +use common::block_on; use http::StatusCode; +use keys::{Address, AddressBuilder, AddressHashEnum, AddressPrefix, NetworkAddressPrefixes}; use mm2_number::BigDecimal; -use mm2_rpc::data::legacy::CoinInitResponse; +use mm2_rpc::data::legacy::{BalanceResponse, CoinInitResponse}; use mm2_test_helpers::for_tests::{ - assert_coin_not_found_on_balance, disable_coin, enable_bch_with_tokens, enable_slp, my_balance, UtxoRpcMode, + assert_coin_not_found_on_balance, disable_coin, enable_bch_with_tokens, enable_native_bch, enable_slp, my_balance, + MarketMakerIt, UtxoRpcMode, }; -use mm2_test_helpers::structs::{EnableBchWithTokensResponse, EnableSlpResponse, RpcV2Response}; +use mm2_test_helpers::structs::{EnableBchWithTokensResponse, EnableSlpResponse, RpcV2Response, TransactionDetails}; use serde_json::{self as json, json, Value as Json}; use std::collections::HashSet; +use std::thread; use std::time::Duration; +// ============================================================================ +// SLP-specific helper functions +// ============================================================================ + +fn slp_supplied_node() -> MarketMakerIt { + let coins = json! ([ + {"coin":"FORSLP","asset":"FORSLP","required_confirmations":0,"txversion":4,"overwintered":1,"txfee":1000,"protocol":{"type":"BCH","protocol_data":{"slp_prefix":"slptest"}}}, + {"coin":"ADEXSLP","protocol":{"type":"SLPTOKEN","protocol_data":{"decimals":8,"token_id":get_slp_token_id(),"platform":"FORSLP"}}} + ]); + + let priv_key = get_prefilled_slp_privkey(); + MarketMakerIt::start( + json! ({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap() +} + +fn get_balance(mm: &MarketMakerIt, coin: &str) -> BalanceResponse { + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "method": "my_balance", + "coin": coin, + }))) + .unwrap(); + assert_eq!(rc.0, StatusCode::OK, "my_balance request failed {}", rc.1); + json::from_str(&rc.1).unwrap() +} + +fn utxo_burn_address() -> Address { + AddressBuilder::new( + UtxoAddressFormat::Standard, + ChecksumType::DSHA256, + NetworkAddressPrefixes { + p2pkh: [60].into(), + p2sh: AddressPrefix::default(), + }, + None, + ) + .as_pkh(AddressHashEnum::default_address_hash()) + .build() + .expect("valid address props") +} + +fn withdraw_max_and_send_v1(mm: &MarketMakerIt, coin: &str, to: &str) -> TransactionDetails { + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "method": "withdraw", + "coin": coin, + "max": true, + "to": to, + }))) + .unwrap(); + assert_eq!(rc.0, StatusCode::OK, "withdraw request failed {}", rc.1); + let tx_details: TransactionDetails = json::from_str(&rc.1).unwrap(); + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "method": "send_raw_transaction", + "tx_hex": tx_details.tx_hex, + "coin": coin, + }))) + .unwrap(); + assert_eq!(rc.0, StatusCode::OK, "send_raw_transaction request failed {}", rc.1); + + tx_details +} + async fn enable_bch_with_tokens_without_balance( mm: &MarketMakerIt, platform_coin: &str, @@ -46,16 +130,6 @@ async fn enable_bch_with_tokens_without_balance( json::from_str(&enable.1).unwrap() } -#[test] -fn trade_test_with_maker_slp() { - trade_base_rel(("ADEXSLP", "FORSLP")); -} - -#[test] -fn trade_test_with_taker_slp() { - trade_base_rel(("FORSLP", "ADEXSLP")); -} - #[test] fn test_bch_and_slp_balance() { // MM2 should mark the SLP-related and other UTXOs as unspendable BCH balance diff --git a/mm2src/mm2_main/tests/docker_tests/swap_proto_v2_tests.rs b/mm2src/mm2_main/tests/docker_tests/swap_proto_v2_tests.rs index 0ced4f84d4..6d25ab01a4 100644 --- a/mm2src/mm2_main/tests/docker_tests/swap_proto_v2_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/swap_proto_v2_tests.rs @@ -1,4 +1,5 @@ -use crate::{generate_utxo_coin_with_random_privkey, MYCOIN, MYCOIN1, SET_BURN_PUBKEY_TO_ALICE}; +use crate::docker_tests::helpers::env::SET_BURN_PUBKEY_TO_ALICE; +use crate::docker_tests::helpers::utxo::{generate_utxo_coin_with_random_privkey, MYCOIN, MYCOIN1}; use bitcrypto::dhash160; use coins::utxo::UtxoCommonOps; use coins::{ @@ -18,6 +19,7 @@ use mm2_test_helpers::for_tests::{ }; use mm2_test_helpers::structs::MmNumberMultiRepr; use script::{Builder, Opcode}; +use serde_json::json; use serialization::serialize; use std::time::Duration; use uuid::Uuid; @@ -29,6 +31,7 @@ fn send_and_refund_taker_funding_timelock() { let funding_time_lock = now_sec() - 1000; let taker_secret_hash = &[0; 20]; let maker_pub = coin.my_public_key().unwrap(); + let maker_pub = &maker_pub; let dex_fee = &DexFee::Standard("0.01".into()); let send_args = SendTakerFundingArgs { @@ -71,10 +74,11 @@ fn send_and_refund_taker_funding_timelock() { }; block_on(coin.validate_taker_funding(validate_args)).unwrap(); + let pubkey = coin.my_public_key().unwrap(); let refund_args = RefundTakerPaymentArgs { payment_tx: &serialize(&taker_funding_utxo_tx).take(), time_lock: funding_time_lock, - maker_pub: coin.my_public_key().unwrap(), + maker_pub: &pubkey, tx_type_with_secret_hash: SwapTxTypeWithSecretHash::TakerFunding { taker_secret_hash: &[0; 20], }, @@ -122,7 +126,7 @@ fn send_and_refund_taker_funding_secret() { payment_time_lock: 0, taker_secret_hash, maker_secret_hash: &[0; 20], - maker_pub, + maker_pub: &maker_pub, dex_fee, premium_amount: "0.1".parse().unwrap(), trading_amount: 1.into(), @@ -149,7 +153,7 @@ fn send_and_refund_taker_funding_secret() { payment_time_lock: 0, taker_secret_hash, maker_secret_hash: &[], - taker_pub: maker_pub, + taker_pub: &maker_pub, dex_fee, premium_amount: "0.1".parse().unwrap(), trading_amount: 1.into(), @@ -161,7 +165,7 @@ fn send_and_refund_taker_funding_secret() { funding_tx: &taker_funding_utxo_tx, funding_time_lock, payment_time_lock: 0, - maker_pubkey: maker_pub, + maker_pubkey: &maker_pub, taker_secret, taker_secret_hash, maker_secret_hash: &[], @@ -214,7 +218,7 @@ fn send_and_spend_taker_funding() { payment_time_lock: 0, taker_secret_hash, maker_secret_hash: &[0; 20], - maker_pub, + maker_pub: &maker_pub, dex_fee, premium_amount: "0.1".parse().unwrap(), trading_amount: 1.into(), @@ -241,7 +245,7 @@ fn send_and_spend_taker_funding() { funding_time_lock, taker_secret_hash, maker_secret_hash: &[], - taker_pub, + taker_pub: &taker_pub, dex_fee, premium_amount: "0.1".parse().unwrap(), trading_amount: 1.into(), @@ -251,8 +255,8 @@ fn send_and_spend_taker_funding() { let preimage_args = GenTakerFundingSpendArgs { funding_tx: &taker_funding_utxo_tx, - maker_pub, - taker_pub, + maker_pub: &maker_pub, + taker_pub: &taker_pub, funding_time_lock, taker_secret_hash, taker_payment_time_lock: 0, @@ -305,7 +309,7 @@ fn send_and_spend_taker_payment_dex_fee_burn_kmd() { payment_time_lock: 0, taker_secret_hash, maker_secret_hash, - maker_pub, + maker_pub: &maker_pub, dex_fee, premium_amount: 0.into(), trading_amount: 777.into(), @@ -332,7 +336,7 @@ fn send_and_spend_taker_payment_dex_fee_burn_kmd() { payment_time_lock: 0, taker_secret_hash, maker_secret_hash, - taker_pub, + taker_pub: &taker_pub, dex_fee, premium_amount: 0.into(), trading_amount: 777.into(), @@ -342,8 +346,8 @@ fn send_and_spend_taker_payment_dex_fee_burn_kmd() { let preimage_args = GenTakerFundingSpendArgs { funding_tx: &taker_funding_utxo_tx, - maker_pub, - taker_pub, + maker_pub: &maker_pub, + taker_pub: &taker_pub, funding_time_lock, taker_secret_hash, taker_payment_time_lock: 0, @@ -358,9 +362,9 @@ fn send_and_spend_taker_payment_dex_fee_burn_kmd() { taker_tx: &payment_tx, time_lock: 0, maker_secret_hash, - maker_pub, + maker_pub: &maker_pub, maker_address: &block_on(maker_coin.my_addr()), - taker_pub, + taker_pub: &taker_pub, dex_fee, premium_amount: 0.into(), trading_amount: 777.into(), @@ -411,7 +415,7 @@ fn send_and_spend_taker_payment_dex_fee_burn_non_kmd() { payment_time_lock: 0, taker_secret_hash, maker_secret_hash, - maker_pub, + maker_pub: &maker_pub, dex_fee, premium_amount: 0.into(), trading_amount: 777.into(), @@ -438,7 +442,7 @@ fn send_and_spend_taker_payment_dex_fee_burn_non_kmd() { payment_time_lock: 0, taker_secret_hash, maker_secret_hash, - taker_pub, + taker_pub: &taker_pub, dex_fee, premium_amount: 0.into(), trading_amount: 777.into(), @@ -448,8 +452,8 @@ fn send_and_spend_taker_payment_dex_fee_burn_non_kmd() { let preimage_args = GenTakerFundingSpendArgs { funding_tx: &taker_funding_utxo_tx, - maker_pub, - taker_pub, + maker_pub: &maker_pub, + taker_pub: &taker_pub, funding_time_lock, taker_secret_hash, taker_payment_time_lock: 0, @@ -464,9 +468,9 @@ fn send_and_spend_taker_payment_dex_fee_burn_non_kmd() { taker_tx: &payment_tx, time_lock: 0, maker_secret_hash, - maker_pub, + maker_pub: &maker_pub, maker_address: &block_on(maker_coin.my_addr()), - taker_pub, + taker_pub: &taker_pub, dex_fee, premium_amount: 0.into(), trading_amount: 777.into(), @@ -514,7 +518,7 @@ fn send_and_refund_maker_payment_timelock() { taker_secret_hash, maker_secret_hash, amount: 1.into(), - taker_pub, + taker_pub: &taker_pub, swap_unique_data: &[], }; let maker_payment = block_on(coin.send_maker_payment_v2(send_args)).unwrap(); @@ -540,14 +544,14 @@ fn send_and_refund_maker_payment_timelock() { maker_secret_hash, amount: 1.into(), swap_unique_data: &[], - maker_pub, + maker_pub: &maker_pub, }; block_on(coin.validate_maker_payment_v2(validate_args)).unwrap(); let refund_args = RefundMakerPaymentTimelockArgs { payment_tx: &serialize(&maker_payment).take(), time_lock, - taker_pub: coin.my_public_key().unwrap(), + taker_pub: &taker_pub, tx_type_with_secret_hash: SwapTxTypeWithSecretHash::MakerPaymentV2 { taker_secret_hash, maker_secret_hash, @@ -578,7 +582,7 @@ fn send_and_refund_maker_payment_taker_secret() { taker_secret_hash, maker_secret_hash, amount: 1.into(), - taker_pub, + taker_pub: &taker_pub, swap_unique_data: &[], }; let maker_payment = block_on(coin.send_maker_payment_v2(send_args)).unwrap(); @@ -604,7 +608,7 @@ fn send_and_refund_maker_payment_taker_secret() { maker_secret_hash, amount: 1.into(), swap_unique_data: &[], - maker_pub, + maker_pub: &maker_pub, }; block_on(coin.validate_maker_payment_v2(validate_args)).unwrap(); @@ -615,7 +619,7 @@ fn send_and_refund_maker_payment_taker_secret() { maker_secret_hash, swap_unique_data: &[], taker_secret, - taker_pub, + taker_pub: &taker_pub, amount: Default::default(), }; @@ -721,10 +725,12 @@ fn test_v2_swap_utxo_utxo_impl() { let locked_alice = block_on(get_locked_amount(&mm_alice, MYCOIN1)); assert_eq!(locked_alice.coin, MYCOIN1); + // With 2% fee rate: locked = volume + dex_fee + tx_fees + // = 777 + (777 * 0.02) + 0.00000274 = 777 + 15.54 + 0.00000274 = 792.54000274 let expected: MmNumberMultiRepr = if SET_BURN_PUBKEY_TO_ALICE.get() { MmNumber::from("777.00000274").into() } else { - MmNumber::from("778.00000274").into() + MmNumber::from("792.54000274").into() }; assert_eq!(locked_alice.locked_amount, expected); @@ -851,7 +857,8 @@ fn test_v2_swap_utxo_utxo_kickstart() { // coins must be virtually locked after kickstart until swap transactions are sent let locked_alice = block_on(get_locked_amount(&mm_alice, MYCOIN1)); assert_eq!(locked_alice.coin, MYCOIN1); - let expected: MmNumberMultiRepr = MmNumber::from("778.00000274").into(); + // With 2% fee rate: locked = volume + dex_fee + tx_fees = 777 + 15.54 + 0.00000274 + let expected: MmNumberMultiRepr = MmNumber::from("792.54000274").into(); assert_eq!(locked_alice.locked_amount, expected); let locked_bob = block_on(get_locked_amount(&mm_bob, MYCOIN)); diff --git a/mm2src/mm2_main/tests/docker_tests/swap_tests.rs b/mm2src/mm2_main/tests/docker_tests/swap_tests.rs new file mode 100644 index 0000000000..e50596fecf --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/swap_tests.rs @@ -0,0 +1,25 @@ +//! SLP platform swap tests. +//! +//! These tests exercise swaps where a platform coin and its SLP token are traded +//! against each other on the same underlying chain: +//! - `FORSLP` (BCH-like UTXO chain with SLP support) +//! - `ADEXSLP` (SLP token on `FORSLP`) +//! +//! This is not a multi-chain integration scenario; it only requires the `FORSLP` +//! docker node. + +use crate::docker_tests::helpers::swap::trade_base_rel; + +/// Test atomic swap with SLP token as maker coin. +/// Requires: FORSLP node only (both coins are on the same platform) +#[test] +fn trade_test_with_maker_slp() { + trade_base_rel(("ADEXSLP", "FORSLP")); +} + +/// Test atomic swap with SLP token as taker coin. +/// Requires: FORSLP node only (both coins are on the same platform) +#[test] +fn trade_test_with_taker_slp() { + trade_base_rel(("FORSLP", "ADEXSLP")); +} diff --git a/mm2src/mm2_main/tests/docker_tests/swap_watcher_tests.rs b/mm2src/mm2_main/tests/docker_tests/swap_watcher_tests/eth.rs similarity index 59% rename from mm2src/mm2_main/tests/docker_tests/swap_watcher_tests.rs rename to mm2src/mm2_main/tests/docker_tests/swap_watcher_tests/eth.rs index 67799a3f30..4bc5989bee 100644 --- a/mm2src/mm2_main/tests/docker_tests/swap_watcher_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/swap_watcher_tests/eth.rs @@ -1,782 +1,9 @@ -use crate::docker_tests::docker_tests_common::GETH_RPC_URL; -use crate::docker_tests::eth_docker_tests::{ - erc20_coin_with_random_privkey, erc20_contract_checksum, eth_coin_with_random_privkey, watchers_swap_contract, -}; -use crate::integration_tests_common::*; -use crate::{generate_utxo_coin_with_privkey, generate_utxo_coin_with_random_privkey, random_secp256k1_secret}; -use coins::coin_errors::ValidatePaymentError; -use coins::eth::{checksum_address, EthCoin}; -use coins::utxo::utxo_standard::UtxoStandardCoin; -use coins::utxo::{dhash160, UtxoCommonOps}; -use coins::{ - ConfirmPaymentInput, DexFee, FoundSwapTxSpend, MarketCoinOps, MmCoin, MmCoinEnum, RefundPaymentArgs, RewardTarget, - SearchForSwapTxSpendInput, SendMakerPaymentSpendPreimageInput, SendPaymentArgs, SwapOps, SwapTxTypeWithSecretHash, - TestCoin, ValidateWatcherSpendInput, WatcherOps, WatcherSpendType, WatcherValidatePaymentInput, - WatcherValidateTakerFeeInput, EARLY_CONFIRMATION_ERR_LOG, INVALID_CONTRACT_ADDRESS_ERR_LOG, - INVALID_PAYMENT_STATE_ERR_LOG, INVALID_RECEIVER_ERR_LOG, INVALID_REFUND_TX_ERR_LOG, INVALID_SCRIPT_ERR_LOG, - INVALID_SENDER_ERR_LOG, INVALID_SWAP_ID_ERR_LOG, OLD_TRANSACTION_ERR_LOG, -}; -use common::{block_on, block_on_f01, now_sec, wait_until_sec}; -use crypto::privkey::{key_pair_from_secret, key_pair_from_seed}; -use mm2_main::lp_swap::{ - generate_secret, get_payment_locktime, MAKER_PAYMENT_SENT_LOG, MAKER_PAYMENT_SPEND_FOUND_LOG, - MAKER_PAYMENT_SPEND_SENT_LOG, REFUND_TEST_FAILURE_LOG, TAKER_PAYMENT_REFUND_SENT_LOG, WATCHER_MESSAGE_SENT_LOG, -}; -use mm2_number::BigDecimal; -use mm2_number::MmNumber; -use mm2_test_helpers::for_tests::{ - enable_eth_coin, erc20_dev_conf, eth_dev_conf, eth_jst_testnet_conf, mm_dump, my_balance, my_swap_status, - mycoin1_conf, mycoin_conf, start_swaps, wait_for_swaps_finish_and_check_status, MarketMakerIt, Mm2TestConf, - DEFAULT_RPC_PASSWORD, -}; -use mm2_test_helpers::get_passphrase; -use mm2_test_helpers::structs::WatcherConf; -use mocktopus::mocking::*; -use num_traits::{One, Zero}; -use primitives::hash::H256; -use serde_json::Value; -use std::str::FromStr; -use std::thread; -use std::time::Duration; -use uuid::Uuid; - -#[derive(Debug, Clone)] -struct BalanceResult { - alice_acoin_balance_before: BigDecimal, - alice_acoin_balance_middle: BigDecimal, - alice_acoin_balance_after: BigDecimal, - alice_bcoin_balance_before: BigDecimal, - alice_bcoin_balance_middle: BigDecimal, - alice_bcoin_balance_after: BigDecimal, - alice_eth_balance_middle: BigDecimal, - alice_eth_balance_after: BigDecimal, - bob_acoin_balance_before: BigDecimal, - bob_acoin_balance_after: BigDecimal, - bob_bcoin_balance_before: BigDecimal, - bob_bcoin_balance_after: BigDecimal, - watcher_acoin_balance_before: BigDecimal, - watcher_acoin_balance_after: BigDecimal, - watcher_bcoin_balance_before: BigDecimal, - watcher_bcoin_balance_after: BigDecimal, -} - -fn enable_coin(mm_node: &MarketMakerIt, coin: &str) { - if coin == "MYCOIN" { - log!("{:?}", block_on(enable_native(mm_node, coin, &[], None))); - } else { - enable_eth(mm_node, coin); - } -} - -fn enable_eth(mm_node: &MarketMakerIt, coin: &str) { - dbg!(block_on(enable_eth_coin( - mm_node, - coin, - &[GETH_RPC_URL], - &checksum_address(&format!("{:02x}", watchers_swap_contract())), - Some(&checksum_address(&format!("{:02x}", watchers_swap_contract()))), - true - ))); -} - -#[allow(clippy::enum_variant_names)] -enum SwapFlow { - WatcherSpendsMakerPayment, - WatcherRefundsTakerPayment, - TakerSpendsMakerPayment, -} - -#[allow(clippy::too_many_arguments)] -fn start_swaps_and_get_balances( - a_coin: &'static str, - b_coin: &'static str, - maker_price: f64, - taker_price: f64, - volume: f64, - envs: &[(&str, &str)], - swap_flow: SwapFlow, - alice_privkey: &str, - bob_privkey: &str, - watcher_privkey: &str, - custom_locktime: Option, -) -> BalanceResult { - let coins = json!([ - eth_dev_conf(), - erc20_dev_conf(&erc20_contract_checksum()), - mycoin_conf(1000), - mycoin1_conf(1000) - ]); - - let mut alice_conf = Mm2TestConf::seednode(&format!("0x{alice_privkey}"), &coins); - if let Some(locktime) = custom_locktime { - alice_conf.conf["payment_locktime"] = locktime.into(); - } - let mut mm_alice = block_on(MarketMakerIt::start_with_envs( - alice_conf.conf.clone(), - alice_conf.rpc_password.clone(), - None, - envs, - )) - .unwrap(); - let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); - log!("Alice log path: {}", mm_alice.log_path.display()); - - let mut bob_conf = Mm2TestConf::light_node(&format!("0x{bob_privkey}"), &coins, &[&mm_alice.ip.to_string()]); - if let Some(locktime) = custom_locktime { - bob_conf.conf["payment_locktime"] = locktime.into(); - } - let mut mm_bob = block_on(MarketMakerIt::start_with_envs( - bob_conf.conf.clone(), - bob_conf.rpc_password, - None, - envs, - )) - .unwrap(); - let (_bob_dump_log, _bob_dump_dashboard) = mm_bob.mm_dump(); - log!("Bob log path: {}", mm_bob.log_path.display()); - - generate_utxo_coin_with_privkey("MYCOIN", 100.into(), H256::from_str(bob_privkey).unwrap()); - generate_utxo_coin_with_privkey("MYCOIN", 100.into(), H256::from_str(alice_privkey).unwrap()); - generate_utxo_coin_with_privkey("MYCOIN1", 100.into(), H256::from_str(bob_privkey).unwrap()); - generate_utxo_coin_with_privkey("MYCOIN1", 100.into(), H256::from_str(alice_privkey).unwrap()); - - let (watcher_conf, watcher_log_to_wait) = match swap_flow { - SwapFlow::WatcherSpendsMakerPayment => ( - WatcherConf { - wait_taker_payment: 0., - wait_maker_payment_spend_factor: 0., - refund_start_factor: 1.5, - search_interval: 1.0, - }, - MAKER_PAYMENT_SPEND_SENT_LOG, - ), - SwapFlow::WatcherRefundsTakerPayment => ( - WatcherConf { - wait_taker_payment: 0., - wait_maker_payment_spend_factor: 1., - refund_start_factor: 0., - search_interval: 1., - }, - TAKER_PAYMENT_REFUND_SENT_LOG, - ), - SwapFlow::TakerSpendsMakerPayment => ( - WatcherConf { - wait_taker_payment: 0., - wait_maker_payment_spend_factor: 1., - refund_start_factor: 1.5, - search_interval: 1.0, - }, - MAKER_PAYMENT_SPEND_FOUND_LOG, - ), - }; - - let mut watcher_conf = Mm2TestConf::watcher_light_node( - &format!("0x{watcher_privkey}"), - &coins, - &[&mm_alice.ip.to_string()], - watcher_conf, - ) - .conf; - if let Some(locktime) = custom_locktime { - watcher_conf["payment_locktime"] = locktime.into(); - } - - let mut mm_watcher = block_on(MarketMakerIt::start_with_envs( - watcher_conf, - DEFAULT_RPC_PASSWORD.to_string(), - None, - envs, - )) - .unwrap(); - let (_watcher_dump_log, _watcher_dump_dashboard) = mm_dump(&mm_watcher.log_path); - log!("Watcher log path: {}", mm_watcher.log_path.display()); - - enable_coin(&mm_alice, a_coin); - enable_coin(&mm_alice, b_coin); - enable_coin(&mm_bob, a_coin); - enable_coin(&mm_bob, b_coin); - enable_coin(&mm_watcher, a_coin); - enable_coin(&mm_watcher, b_coin); - - if a_coin != "ETH" && b_coin != "ETH" { - enable_coin(&mm_alice, "ETH"); - } - - let alice_acoin_balance_before = block_on(my_balance(&mm_alice, a_coin)).balance; - let alice_bcoin_balance_before = block_on(my_balance(&mm_alice, b_coin)).balance; - let bob_acoin_balance_before = block_on(my_balance(&mm_bob, a_coin)).balance; - let bob_bcoin_balance_before = block_on(my_balance(&mm_bob, b_coin)).balance; - let watcher_acoin_balance_before = block_on(my_balance(&mm_watcher, a_coin)).balance; - let watcher_bcoin_balance_before = block_on(my_balance(&mm_watcher, b_coin)).balance; - - let mut alice_acoin_balance_middle = BigDecimal::zero(); - let mut alice_bcoin_balance_middle = BigDecimal::zero(); - let mut alice_eth_balance_middle = BigDecimal::zero(); - let mut bob_acoin_balance_after = BigDecimal::zero(); - let mut bob_bcoin_balance_after = BigDecimal::zero(); - - block_on(start_swaps( - &mut mm_bob, - &mut mm_alice, - &[(b_coin, a_coin)], - maker_price, - taker_price, - volume, - )); - - if matches!(swap_flow, SwapFlow::WatcherRefundsTakerPayment) { - block_on(mm_bob.wait_for_log(120., |log| log.contains(MAKER_PAYMENT_SENT_LOG))).unwrap(); - block_on(mm_bob.stop()).unwrap(); - } - if !matches!(swap_flow, SwapFlow::TakerSpendsMakerPayment) { - block_on(mm_alice.wait_for_log(120., |log| log.contains(WATCHER_MESSAGE_SENT_LOG))).unwrap(); - alice_acoin_balance_middle = block_on(my_balance(&mm_alice, a_coin)).balance; - alice_bcoin_balance_middle = block_on(my_balance(&mm_alice, b_coin)).balance; - alice_eth_balance_middle = block_on(my_balance(&mm_alice, "ETH")).balance; - block_on(mm_alice.stop()).unwrap(); - } - - block_on(mm_watcher.wait_for_log(120., |log| log.contains(watcher_log_to_wait))).unwrap(); - thread::sleep(Duration::from_secs(20)); - - let mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); - enable_coin(&mm_alice, a_coin); - enable_coin(&mm_alice, b_coin); - - if a_coin != "ETH" && b_coin != "ETH" { - enable_coin(&mm_alice, "ETH"); - } - - let alice_acoin_balance_after = block_on(my_balance(&mm_alice, a_coin)).balance; - let alice_bcoin_balance_after = block_on(my_balance(&mm_alice, b_coin)).balance; - let alice_eth_balance_after = block_on(my_balance(&mm_alice, "ETH")).balance; - if !matches!(swap_flow, SwapFlow::WatcherRefundsTakerPayment) { - bob_acoin_balance_after = block_on(my_balance(&mm_bob, a_coin)).balance; - bob_bcoin_balance_after = block_on(my_balance(&mm_bob, b_coin)).balance; - } - let watcher_acoin_balance_after = block_on(my_balance(&mm_watcher, a_coin)).balance; - let watcher_bcoin_balance_after = block_on(my_balance(&mm_watcher, b_coin)).balance; - - BalanceResult { - alice_acoin_balance_before, - alice_acoin_balance_middle, - alice_acoin_balance_after, - alice_bcoin_balance_before, - alice_bcoin_balance_middle, - alice_bcoin_balance_after, - alice_eth_balance_middle, - alice_eth_balance_after, - bob_acoin_balance_before, - bob_acoin_balance_after, - bob_bcoin_balance_before, - bob_bcoin_balance_after, - watcher_acoin_balance_before, - watcher_acoin_balance_after, - watcher_bcoin_balance_before, - watcher_bcoin_balance_after, - } -} - -fn check_actual_events(mm_alice: &MarketMakerIt, uuid: &str, expected_events: &[&'static str]) -> Value { - let status_response = block_on(my_swap_status(mm_alice, uuid)).unwrap(); - let events_array = status_response["result"]["events"].as_array().unwrap(); - let actual_events = events_array.iter().map(|item| item["event"]["type"].as_str().unwrap()); - let actual_events: Vec<&str> = actual_events.collect(); - assert_eq!(expected_events, actual_events.as_slice()); - status_response -} - -fn run_taker_node( - coins: &Value, - envs: &[(&str, &str)], - seednodes: &[&str], - custom_locktime: Option, -) -> (MarketMakerIt, Mm2TestConf) { - let privkey = hex::encode(random_secp256k1_secret()); - let mut conf = Mm2TestConf::light_node(&format!("0x{privkey}"), coins, seednodes); - if let Some(locktime) = custom_locktime { - conf.conf["payment_locktime"] = locktime.into(); - } - let mm = block_on(MarketMakerIt::start_with_envs( - conf.conf.clone(), - conf.rpc_password.clone(), - None, - envs, - )) - .unwrap(); - let (_dump_log, _dump_dashboard) = mm.mm_dump(); - log!("Log path: {}", mm.log_path.display()); - - generate_utxo_coin_with_privkey("MYCOIN", 100.into(), H256::from_str(&privkey).unwrap()); - generate_utxo_coin_with_privkey("MYCOIN1", 100.into(), H256::from_str(&privkey).unwrap()); - enable_coin(&mm, "MYCOIN"); - enable_coin(&mm, "MYCOIN1"); - - (mm, conf) -} - -fn restart_taker_and_wait_until(conf: &Mm2TestConf, envs: &[(&str, &str)], wait_until: &str) -> MarketMakerIt { - let mut mm_alice = block_on(MarketMakerIt::start_with_envs( - conf.conf.clone(), - conf.rpc_password.clone(), - None, - envs, - )) - .unwrap(); - - let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); - log!("Alice log path: {}", mm_alice.log_path.display()); - enable_coin(&mm_alice, "MYCOIN"); - enable_coin(&mm_alice, "MYCOIN1"); - - block_on(mm_alice.wait_for_log(120., |log| log.contains(wait_until))).unwrap(); - mm_alice -} - -fn run_maker_node( - coins: &Value, - envs: &[(&str, &str)], - seednodes: &[&str], - custom_locktime: Option, -) -> MarketMakerIt { - let privkey = hex::encode(random_secp256k1_secret()); - let mut conf = if seednodes.is_empty() { - Mm2TestConf::seednode(&format!("0x{privkey}"), coins) - } else { - Mm2TestConf::light_node(&format!("0x{privkey}"), coins, seednodes) - }; - if let Some(locktime) = custom_locktime { - conf.conf["payment_locktime"] = locktime.into(); - } - let mm = block_on(MarketMakerIt::start_with_envs( - conf.conf.clone(), - conf.rpc_password, - None, - envs, - )) - .unwrap(); - let (_dump_log, _dump_dashboard) = mm.mm_dump(); - log!("Log path: {}", mm.log_path.display()); - - generate_utxo_coin_with_privkey("MYCOIN", 100.into(), H256::from_str(&privkey).unwrap()); - generate_utxo_coin_with_privkey("MYCOIN1", 100.into(), H256::from_str(&privkey).unwrap()); - enable_coin(&mm, "MYCOIN"); - enable_coin(&mm, "MYCOIN1"); - - mm -} - -fn run_watcher_node( - coins: &Value, - envs: &[(&str, &str)], - seednodes: &[&str], - watcher_conf: WatcherConf, - custom_locktime: Option, -) -> MarketMakerIt { - let privkey = hex::encode(random_secp256k1_secret()); - let mut conf = Mm2TestConf::watcher_light_node(&format!("0x{privkey}"), coins, seednodes, watcher_conf).conf; - if let Some(locktime) = custom_locktime { - conf["payment_locktime"] = locktime.into(); - } - let mm = block_on(MarketMakerIt::start_with_envs( - conf, - DEFAULT_RPC_PASSWORD.to_string(), - None, - envs, - )) - .unwrap(); - let (_dump_log, _dump_dashboard) = mm.mm_dump(); - log!("Log path: {}", mm.log_path.display()); - - generate_utxo_coin_with_privkey("MYCOIN", 100.into(), H256::from_str(&privkey).unwrap()); - generate_utxo_coin_with_privkey("MYCOIN1", 100.into(), H256::from_str(&privkey).unwrap()); - enable_coin(&mm, "MYCOIN"); - enable_coin(&mm, "MYCOIN1"); - - mm -} - -#[test] -fn test_taker_saves_the_swap_as_successful_after_restart_panic_at_wait_for_taker_payment_spend() { - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut mm_bob = run_maker_node(&coins, &[], &[], None); - let (mut mm_alice, mut alice_conf) = run_taker_node( - &coins, - &[("TAKER_FAIL_AT", "wait_for_taker_payment_spend_panic")], - &[&mm_bob.ip.to_string()], - None, - ); - - let watcher_conf = WatcherConf { - wait_taker_payment: 0., - wait_maker_payment_spend_factor: 0., - refund_start_factor: 1.5, - search_interval: 1.0, - }; - let mut mm_watcher = run_watcher_node(&coins, &[], &[&mm_bob.ip.to_string()], watcher_conf, None); - - let uuids = block_on(start_swaps( - &mut mm_bob, - &mut mm_alice, - &[("MYCOIN1", "MYCOIN")], - 25., - 25., - 2., - )); - let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); - log!("Alice log path: {}", mm_alice.log_path.display()); - alice_conf.conf["dbdir"] = mm_alice.folder.join("DB").to_str().unwrap().into(); - - block_on(mm_alice.wait_for_log(120., |log| log.contains(WATCHER_MESSAGE_SENT_LOG))).unwrap(); - block_on(mm_bob.wait_for_log(120., |log| log.contains(&format!("[swap uuid={}] Finished", &uuids[0])))).unwrap(); - block_on(mm_watcher.wait_for_log(120., |log| log.contains(MAKER_PAYMENT_SPEND_SENT_LOG))).unwrap(); - - block_on(mm_alice.stop()).unwrap(); - - let mm_alice = restart_taker_and_wait_until(&alice_conf, &[], &format!("[swap uuid={}] Finished", &uuids[0])); - - let expected_events = [ - "Started", - "Negotiated", - "TakerFeeSent", - "TakerPaymentInstructionsReceived", - "MakerPaymentReceived", - "MakerPaymentWaitConfirmStarted", - "MakerPaymentValidatedAndConfirmed", - "TakerPaymentSent", - "WatcherMessageSent", - "TakerPaymentSpent", - "MakerPaymentSpentByWatcher", - "MakerPaymentSpendConfirmed", - "Finished", - ]; - check_actual_events(&mm_alice, &uuids[0], &expected_events); - - block_on(mm_alice.stop()).unwrap(); - block_on(mm_watcher.stop()).unwrap(); - block_on(mm_bob.stop()).unwrap(); -} - -#[test] -fn test_taker_saves_the_swap_as_successful_after_restart_panic_at_maker_payment_spend() { - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut mm_bob = run_maker_node(&coins, &[], &[], None); - let (mut mm_alice, mut alice_conf) = run_taker_node( - &coins, - &[("TAKER_FAIL_AT", "maker_payment_spend_panic")], - &[&mm_bob.ip.to_string()], - None, - ); - - let watcher_conf = WatcherConf { - wait_taker_payment: 0., - wait_maker_payment_spend_factor: 0., - refund_start_factor: 1.5, - search_interval: 1.0, - }; - let mut mm_watcher = run_watcher_node(&coins, &[], &[&mm_bob.ip.to_string()], watcher_conf, None); - - let uuids = block_on(start_swaps( - &mut mm_bob, - &mut mm_alice, - &[("MYCOIN1", "MYCOIN")], - 25., - 25., - 2., - )); - let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); - log!("Alice log path: {}", mm_alice.log_path.display()); - alice_conf.conf["dbdir"] = mm_alice.folder.join("DB").to_str().unwrap().into(); - - block_on(mm_alice.wait_for_log(120., |log| log.contains(WATCHER_MESSAGE_SENT_LOG))).unwrap(); - block_on(mm_bob.wait_for_log(120., |log| log.contains(&format!("[swap uuid={}] Finished", &uuids[0])))).unwrap(); - block_on(mm_watcher.wait_for_log(120., |log| log.contains(MAKER_PAYMENT_SPEND_SENT_LOG))).unwrap(); - - block_on(mm_alice.stop()).unwrap(); - - let mm_alice = restart_taker_and_wait_until(&alice_conf, &[], &format!("[swap uuid={}] Finished", &uuids[0])); - - let expected_events = [ - "Started", - "Negotiated", - "TakerFeeSent", - "TakerPaymentInstructionsReceived", - "MakerPaymentReceived", - "MakerPaymentWaitConfirmStarted", - "MakerPaymentValidatedAndConfirmed", - "TakerPaymentSent", - "WatcherMessageSent", - "TakerPaymentSpent", - "MakerPaymentSpentByWatcher", - "MakerPaymentSpendConfirmed", - "Finished", - ]; - check_actual_events(&mm_alice, &uuids[0], &expected_events); -} - -#[test] -fn test_taker_saves_the_swap_as_finished_after_restart_taker_payment_refunded_panic_at_wait_for_taker_payment_spend() { - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mm_seednode = run_maker_node(&coins, &[], &[], Some(60)); - let mut mm_bob = run_maker_node(&coins, &[], &[&mm_seednode.ip.to_string()], Some(60)); - let (mut mm_alice, mut alice_conf) = run_taker_node( - &coins, - &[("TAKER_FAIL_AT", "wait_for_taker_payment_spend_panic")], - &[&mm_seednode.ip.to_string()], - Some(60), - ); - - let watcher_conf = WatcherConf { - wait_taker_payment: 0., - wait_maker_payment_spend_factor: 1., - refund_start_factor: 0., - search_interval: 1., - }; - let mut mm_watcher = run_watcher_node(&coins, &[], &[&mm_seednode.ip.to_string()], watcher_conf, Some(60)); - - let uuids = block_on(start_swaps( - &mut mm_bob, - &mut mm_alice, - &[("MYCOIN1", "MYCOIN")], - 25., - 25., - 2., - )); - let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); - log!("Alice log path: {}", mm_alice.log_path.display()); - alice_conf.conf["dbdir"] = mm_alice.folder.join("DB").to_str().unwrap().into(); - - block_on(mm_bob.wait_for_log(120., |log| log.contains(MAKER_PAYMENT_SENT_LOG))).unwrap(); - block_on(mm_bob.stop()).unwrap(); - - block_on(mm_alice.wait_for_log(120., |log| log.contains(WATCHER_MESSAGE_SENT_LOG))).unwrap(); - block_on(mm_watcher.wait_for_log(120., |log| log.contains(TAKER_PAYMENT_REFUND_SENT_LOG))).unwrap(); - - block_on(mm_alice.stop()).unwrap(); - - let mm_alice = restart_taker_and_wait_until(&alice_conf, &[], &format!("[swap uuid={}] Finished", &uuids[0])); - - let expected_events = [ - "Started", - "Negotiated", - "TakerFeeSent", - "TakerPaymentInstructionsReceived", - "MakerPaymentReceived", - "MakerPaymentWaitConfirmStarted", - "MakerPaymentValidatedAndConfirmed", - "TakerPaymentSent", - "WatcherMessageSent", - "TakerPaymentRefundedByWatcher", - "Finished", - ]; - check_actual_events(&mm_alice, &uuids[0], &expected_events); - - block_on(mm_alice.stop()).unwrap(); - block_on(mm_watcher.stop()).unwrap(); - block_on(mm_seednode.stop()).unwrap(); -} - -#[test] -fn test_taker_saves_the_swap_as_finished_after_restart_taker_payment_refunded_panic_at_taker_payment_refund() { - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mm_seednode = run_maker_node(&coins, &[], &[], Some(60)); - let mut mm_bob = run_maker_node(&coins, &[], &[&mm_seednode.ip.to_string()], Some(60)); - let (mut mm_alice, mut alice_conf) = run_taker_node( - &coins, - &[("TAKER_FAIL_AT", "taker_payment_refund_panic")], - &[&mm_seednode.ip.to_string()], - Some(60), - ); - - let watcher_conf = WatcherConf { - wait_taker_payment: 0., - wait_maker_payment_spend_factor: 1., - refund_start_factor: 0., - search_interval: 1., - }; - let mut mm_watcher = run_watcher_node(&coins, &[], &[&mm_seednode.ip.to_string()], watcher_conf, Some(60)); - - let uuids = block_on(start_swaps( - &mut mm_bob, - &mut mm_alice, - &[("MYCOIN1", "MYCOIN")], - 25., - 25., - 2., - )); - let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); - log!("Alice log path: {}", mm_alice.log_path.display()); - alice_conf.conf["dbdir"] = mm_alice.folder.join("DB").to_str().unwrap().into(); - - block_on(mm_bob.wait_for_log(120., |log| log.contains(MAKER_PAYMENT_SENT_LOG))).unwrap(); - block_on(mm_bob.stop()).unwrap(); - - block_on(mm_alice.wait_for_log(120., |log| log.contains(REFUND_TEST_FAILURE_LOG))).unwrap(); - block_on(mm_watcher.wait_for_log(120., |log| log.contains(TAKER_PAYMENT_REFUND_SENT_LOG))).unwrap(); - - block_on(mm_alice.stop()).unwrap(); - - let mm_alice = restart_taker_and_wait_until(&alice_conf, &[], &format!("[swap uuid={}] Finished", &uuids[0])); - - let expected_events = [ - "Started", - "Negotiated", - "TakerFeeSent", - "TakerPaymentInstructionsReceived", - "MakerPaymentReceived", - "MakerPaymentWaitConfirmStarted", - "MakerPaymentValidatedAndConfirmed", - "TakerPaymentSent", - "WatcherMessageSent", - "TakerPaymentWaitForSpendFailed", - "TakerPaymentWaitRefundStarted", - "TakerPaymentRefundStarted", - "TakerPaymentRefundedByWatcher", - "Finished", - ]; - check_actual_events(&mm_alice, &uuids[0], &expected_events); - - block_on(mm_alice.stop()).unwrap(); - block_on(mm_watcher.stop()).unwrap(); - block_on(mm_seednode.stop()).unwrap(); -} - -#[test] -fn test_taker_completes_swap_after_restart() { - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut mm_bob = run_maker_node(&coins, &[], &[], None); - let (mut mm_alice, mut alice_conf) = run_taker_node(&coins, &[], &[&mm_bob.ip.to_string()], None); - - let uuids = block_on(start_swaps( - &mut mm_bob, - &mut mm_alice, - &[("MYCOIN1", "MYCOIN")], - 25., - 25., - 2., - )); - - block_on(mm_alice.wait_for_log(120., |log| log.contains(WATCHER_MESSAGE_SENT_LOG))).unwrap(); - alice_conf.conf["dbdir"] = mm_alice.folder.join("DB").to_str().unwrap().into(); - block_on(mm_alice.stop()).unwrap(); - - let mut mm_alice = block_on(MarketMakerIt::start_with_envs( - alice_conf.conf, - alice_conf.rpc_password.clone(), - None, - &[], - )) - .unwrap(); - - let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); - log!("Alice log path: {}", mm_alice.log_path.display()); - enable_coin(&mm_alice, "MYCOIN"); - enable_coin(&mm_alice, "MYCOIN1"); - - block_on(wait_for_swaps_finish_and_check_status( - &mut mm_bob, - &mut mm_alice, - &uuids, - 2., - 25., - )); - - block_on(mm_alice.stop()).unwrap(); - block_on(mm_bob.stop()).unwrap(); -} - -// Verifies https://github.com/KomodoPlatform/komodo-defi-framework/issues/2111 -#[test] -fn test_taker_completes_swap_after_taker_payment_spent_while_offline() { - let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); - let mut mm_bob = run_maker_node(&coins, &[], &[], None); - let (mut mm_alice, mut alice_conf) = run_taker_node(&coins, &[], &[&mm_bob.ip.to_string()], None); - - let uuids = block_on(start_swaps( - &mut mm_bob, - &mut mm_alice, - &[("MYCOIN1", "MYCOIN")], - 25., - 25., - 2., - )); - - // stop taker after taker payment sent - let taker_payment_msg = "Taker payment tx hash "; - block_on(mm_alice.wait_for_log(120., |log| log.contains(taker_payment_msg))).unwrap(); - // ensure p2p message is sent to the maker, this happens before this message: - block_on(mm_alice.wait_for_log(120., |log| log.contains("Waiting for maker to spend taker payment!"))).unwrap(); - alice_conf.conf["dbdir"] = mm_alice.folder.join("DB").to_str().unwrap().into(); - block_on(mm_alice.stop()).unwrap(); - - // wait for taker payment spent by maker - block_on(mm_bob.wait_for_log(120., |log| log.contains("Taker payment spend tx"))).unwrap(); - // and restart taker - let mut mm_alice = block_on(MarketMakerIt::start_with_envs( - alice_conf.conf, - alice_conf.rpc_password.clone(), - None, - &[], - )) - .unwrap(); - - let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); - log!("Alice log path: {}", mm_alice.log_path.display()); - enable_coin(&mm_alice, "MYCOIN"); - enable_coin(&mm_alice, "MYCOIN1"); - - block_on(wait_for_swaps_finish_and_check_status( - &mut mm_bob, - &mut mm_alice, - &uuids, - 2., - 25., - )); - - block_on(mm_alice.stop()).unwrap(); - block_on(mm_bob.stop()).unwrap(); -} - -#[test] -fn test_watcher_spends_maker_payment_utxo_utxo() { - let alice_privkey = hex::encode(random_secp256k1_secret()); - let bob_privkey = hex::encode(random_secp256k1_secret()); - let watcher_privkey = hex::encode(random_secp256k1_secret()); - - let balances = start_swaps_and_get_balances( - "MYCOIN", - "MYCOIN1", - 25., - 25., - 2., - &[], - SwapFlow::WatcherSpendsMakerPayment, - &alice_privkey, - &bob_privkey, - &watcher_privkey, - None, - ); +//! ETH/ERC20 Watcher Tests +//! +//! These tests are disabled by default because ETH watchers are unstable +//! and not completed yet. Enable with feature `docker-tests-watchers-eth`. - let acoin_volume = BigDecimal::from_str("50").unwrap(); - let bcoin_volume = BigDecimal::from_str("2").unwrap(); - - assert_eq!( - balances.alice_acoin_balance_after.round(0), - balances.alice_acoin_balance_before - acoin_volume.clone() - ); - assert_eq!( - balances.alice_bcoin_balance_after.round(0), - balances.alice_bcoin_balance_before + bcoin_volume.clone() - ); - assert_eq!( - balances.bob_acoin_balance_after.round(0), - balances.bob_acoin_balance_before + acoin_volume - ); - assert_eq!( - balances.bob_bcoin_balance_after.round(0), - balances.bob_bcoin_balance_before - bcoin_volume - ); -} +use super::*; #[test] fn test_watcher_spends_maker_payment_utxo_eth() { @@ -1003,33 +230,6 @@ fn test_watcher_spends_maker_payment_erc20_utxo() { ); } -#[test] -fn test_watcher_refunds_taker_payment_utxo() { - let alice_privkey = &hex::encode(random_secp256k1_secret()); - let bob_privkey = &hex::encode(random_secp256k1_secret()); - let watcher_privkey = &hex::encode(random_secp256k1_secret()); - - let balances = start_swaps_and_get_balances( - "MYCOIN1", - "MYCOIN", - 25., - 25., - 2., - &[], - SwapFlow::WatcherRefundsTakerPayment, - alice_privkey, - bob_privkey, - watcher_privkey, - Some(60), - ); - - assert_eq!( - balances.alice_acoin_balance_after.round(0), - balances.alice_acoin_balance_before - ); - assert_eq!(balances.alice_bcoin_balance_after, balances.alice_bcoin_balance_before); -} - #[test] fn test_watcher_refunds_taker_payment_eth() { let alice_coin = eth_coin_with_random_privkey(watchers_swap_contract()); @@ -1086,27 +286,6 @@ fn test_watcher_refunds_taker_payment_erc20() { assert!(balances.watcher_bcoin_balance_after > balances.watcher_bcoin_balance_before); } -#[test] -fn test_watcher_waits_for_taker_utxo() { - let alice_privkey = &hex::encode(random_secp256k1_secret()); - let bob_privkey = &hex::encode(random_secp256k1_secret()); - let watcher_privkey = &hex::encode(random_secp256k1_secret()); - - start_swaps_and_get_balances( - "MYCOIN1", - "MYCOIN", - 25., - 25., - 2., - &[], - SwapFlow::TakerSpendsMakerPayment, - alice_privkey, - bob_privkey, - watcher_privkey, - None, - ); -} - #[test] fn test_watcher_waits_for_taker_eth() { let alice_coin = erc20_coin_with_random_privkey(watchers_swap_contract()); @@ -1220,25 +399,22 @@ fn test_two_watchers_spend_maker_payment_eth_erc20() { assert_eq!(bob_jst_balance_before + volume.clone(), bob_jst_balance_after); assert_eq!(alice_eth_balance_before + volume.clone(), alice_eth_balance_after); assert_eq!(bob_eth_balance_before - volume, bob_eth_balance_after); - if watcher1_eth_balance_after > watcher1_eth_balance_before { - assert_eq!(watcher2_eth_balance_after, watcher2_eth_balance_after); - } - if watcher2_eth_balance_after > watcher2_eth_balance_before { - assert_eq!(watcher1_eth_balance_after, watcher1_eth_balance_after); - } + let w1_gain = watcher1_eth_balance_after > watcher1_eth_balance_before; + let w2_gain = watcher2_eth_balance_after > watcher2_eth_balance_before; + assert_ne!(w1_gain, w2_gain, "exactly one watcher must receive the reward"); } #[test] -fn test_watcher_validate_taker_fee_utxo() { +fn test_watcher_validate_taker_fee_eth() { let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run let lock_duration = get_payment_locktime(); - let (_ctx, taker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); - let (_ctx, maker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); - let taker_pubkey = taker_coin.my_public_key().unwrap(); - let taker_amount = MmNumber::from((10, 1)); - let dex_fee = DexFee::new_from_taker_coin(&taker_coin, maker_coin.ticker(), &taker_amount); + let taker_coin = eth_coin_with_random_privkey(watchers_swap_contract()); + let taker_keypair = taker_coin.derive_htlc_key_pair(&[]); + let taker_pubkey = taker_keypair.public(); + let taker_amount = MmNumber::from((1, 1)); + let dex_fee = DexFee::new_from_taker_coin(&taker_coin, "ETH", &taker_amount); let taker_fee = block_on(taker_coin.send_taker_fee(dex_fee, Uuid::new_v4().as_bytes(), lock_duration)).unwrap(); let confirm_payment_input = ConfirmPaymentInput { @@ -1248,7 +424,6 @@ fn test_watcher_validate_taker_fee_utxo() { wait_until: timeout, check_every: 1, }; - block_on_f01(taker_coin.wait_for_confirmations(confirm_payment_input)).unwrap(); let validate_taker_fee_res = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { @@ -1259,9 +434,10 @@ fn test_watcher_validate_taker_fee_utxo() { })); assert!(validate_taker_fee_res.is_ok()); + let wrong_keypair = key_pair_from_secret(&random_secp256k1_secret().take()).unwrap(); let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), - sender_pubkey: maker_coin.my_public_key().unwrap().to_vec(), + sender_pubkey: wrong_keypair.public().to_vec(), min_block_number: 0, lock_duration, })) @@ -1292,357 +468,48 @@ fn test_watcher_validate_taker_fee_utxo() { _ => panic!( "Expected `WrongPaymentTx` confirmed before min_block, found {:?}", error - ), - } - - let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { - taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), - sender_pubkey: taker_pubkey.to_vec(), - min_block_number: 0, - lock_duration: 0, - })) - .unwrap_err() - .into_inner(); - log!("error: {:?}", error); - match error { - ValidatePaymentError::WrongPaymentTx(err) => { - assert!(err.contains(OLD_TRANSACTION_ERR_LOG)) - }, - _ => panic!("Expected `WrongPaymentTx` transaction too old, found {:?}", error), - } - - let mock_pubkey = taker_pubkey.to_vec(); - ::dex_pubkey - .mock_safe(move |_| MockResult::Return(Box::leak(Box::new(mock_pubkey.clone())))); - - let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { - taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), - sender_pubkey: taker_pubkey.to_vec(), - min_block_number: 0, - lock_duration, - })) - .unwrap_err() - .into_inner(); - log!("error: {:?}", error); - match error { - ValidatePaymentError::WrongPaymentTx(err) => { - assert!(err.contains(INVALID_RECEIVER_ERR_LOG)) - }, - _ => panic!( - "Expected `WrongPaymentTx` tx output script_pubkey doesn't match expected, found {:?}", - error - ), - } -} - -#[test] -fn test_watcher_validate_taker_fee_eth() { - let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run - let lock_duration = get_payment_locktime(); - - let taker_coin = eth_coin_with_random_privkey(watchers_swap_contract()); - let taker_keypair = taker_coin.derive_htlc_key_pair(&[]); - let taker_pubkey = taker_keypair.public(); - - let taker_amount = MmNumber::from((1, 1)); - let dex_fee = DexFee::new_from_taker_coin(&taker_coin, "ETH", &taker_amount); - let taker_fee = block_on(taker_coin.send_taker_fee(dex_fee, Uuid::new_v4().as_bytes(), lock_duration)).unwrap(); - - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: taker_fee.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(taker_coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - - let validate_taker_fee_res = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { - taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), - sender_pubkey: taker_pubkey.to_vec(), - min_block_number: 0, - lock_duration, - })); - assert!(validate_taker_fee_res.is_ok()); - - let wrong_keypair = key_pair_from_secret(&random_secp256k1_secret().take()).unwrap(); - let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { - taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), - sender_pubkey: wrong_keypair.public().to_vec(), - min_block_number: 0, - lock_duration, - })) - .unwrap_err() - .into_inner(); - - log!("error: {:?}", error); - match error { - ValidatePaymentError::WrongPaymentTx(err) => { - assert!(err.contains(INVALID_SENDER_ERR_LOG)) - }, - _ => panic!("Expected `WrongPaymentTx` invalid public key, found {:?}", error), - } - - let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { - taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), - sender_pubkey: taker_pubkey.to_vec(), - min_block_number: u64::MAX, - lock_duration, - })) - .unwrap_err() - .into_inner(); - log!("error: {:?}", error); - match error { - ValidatePaymentError::WrongPaymentTx(err) => { - assert!(err.contains(EARLY_CONFIRMATION_ERR_LOG)) - }, - _ => panic!( - "Expected `WrongPaymentTx` confirmed before min_block, found {:?}", - error - ), - } - - let mock_pubkey = taker_pubkey.to_vec(); - ::dex_pubkey.mock_safe(move |_| MockResult::Return(Box::leak(Box::new(mock_pubkey.clone())))); - - let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { - taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), - sender_pubkey: taker_pubkey.to_vec(), - min_block_number: 0, - lock_duration, - })) - .unwrap_err() - .into_inner(); - log!("error: {:?}", error); - match error { - ValidatePaymentError::WrongPaymentTx(err) => { - assert!(err.contains(INVALID_RECEIVER_ERR_LOG)) - }, - _ => panic!( - "Expected `WrongPaymentTx` tx output script_pubkey doesn't match expected, found {:?}", - error - ), - } - ::dex_pubkey.clear_mock(); -} - -#[test] -fn test_watcher_validate_taker_fee_erc20() { - let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run - let lock_duration = get_payment_locktime(); - - let taker_coin = erc20_coin_with_random_privkey(watchers_swap_contract()); - let taker_keypair = taker_coin.derive_htlc_key_pair(&[]); - let taker_pubkey = taker_keypair.public(); - - let taker_amount = MmNumber::from((1, 1)); - let dex_fee = DexFee::new_from_taker_coin(&taker_coin, "ETH", &taker_amount); - let taker_fee = block_on(taker_coin.send_taker_fee(dex_fee, Uuid::new_v4().as_bytes(), lock_duration)).unwrap(); - - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: taker_fee.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(taker_coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - - let validate_taker_fee_res = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { - taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), - sender_pubkey: taker_pubkey.to_vec(), - min_block_number: 0, - lock_duration, - })); - assert!(validate_taker_fee_res.is_ok()); - - let wrong_keypair = key_pair_from_secret(&random_secp256k1_secret().take()).unwrap(); - let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { - taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), - sender_pubkey: wrong_keypair.public().to_vec(), - min_block_number: 0, - lock_duration, - })) - .unwrap_err() - .into_inner(); - - log!("error: {:?}", error); - match error { - ValidatePaymentError::WrongPaymentTx(err) => { - assert!(err.contains(INVALID_SENDER_ERR_LOG)) - }, - _ => panic!("Expected `WrongPaymentTx` invalid public key, found {:?}", error), - } - - let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { - taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), - sender_pubkey: taker_pubkey.to_vec(), - min_block_number: u64::MAX, - lock_duration, - })) - .unwrap_err() - .into_inner(); - log!("error: {:?}", error); - match error { - ValidatePaymentError::WrongPaymentTx(err) => { - assert!(err.contains(EARLY_CONFIRMATION_ERR_LOG)) - }, - _ => panic!( - "Expected `WrongPaymentTx` confirmed before min_block, found {:?}", - error - ), - } - - let mock_pubkey = taker_pubkey.to_vec(); - ::dex_pubkey.mock_safe(move |_| MockResult::Return(Box::leak(Box::new(mock_pubkey.clone())))); - - let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { - taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), - sender_pubkey: taker_pubkey.to_vec(), - min_block_number: 0, - lock_duration, - })) - .unwrap_err() - .into_inner(); - log!("error: {:?}", error); - match error { - ValidatePaymentError::WrongPaymentTx(err) => { - assert!(err.contains(INVALID_RECEIVER_ERR_LOG)) - }, - _ => panic!( - "Expected `WrongPaymentTx` tx output script_pubkey doesn't match expected, found {:?}", - error - ), - } - ::dex_pubkey.clear_mock(); -} - -#[test] -fn test_watcher_validate_taker_payment_utxo() { - let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run - let time_lock_duration = get_payment_locktime(); - let wait_for_confirmation_until = wait_until_sec(time_lock_duration); - let time_lock = wait_for_confirmation_until; - - let (_ctx, taker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); - let taker_pubkey = taker_coin.my_public_key().unwrap(); - - let (_ctx, maker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); - let maker_pubkey = maker_coin.my_public_key().unwrap(); - - let secret_hash = dhash160(&generate_secret().unwrap()); - - let taker_payment = block_on(taker_coin.send_taker_payment(SendPaymentArgs { - time_lock_duration, - time_lock, - other_pubkey: maker_pubkey, - secret_hash: secret_hash.as_slice(), - amount: BigDecimal::from(10), - swap_contract_address: &None, - swap_unique_data: &[], - payment_instructions: &None, - watcher_reward: None, - wait_for_confirmation_until, - })) - .unwrap(); - - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: taker_payment.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(taker_coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - - let taker_payment_refund_preimage = block_on_f01(taker_coin.create_taker_payment_refund_preimage( - &taker_payment.tx_hex(), - time_lock, - maker_pubkey, - secret_hash.as_slice(), - &None, - &[], - )) - .unwrap(); - let validate_taker_payment_res = - block_on_f01(taker_coin.watcher_validate_taker_payment(WatcherValidatePaymentInput { - payment_tx: taker_payment.tx_hex(), - taker_payment_refund_preimage: taker_payment_refund_preimage.tx_hex(), - time_lock, - taker_pub: taker_pubkey.to_vec(), - maker_pub: maker_pubkey.to_vec(), - secret_hash: secret_hash.to_vec(), - wait_until: timeout, - confirmations: 1, - maker_coin: MmCoinEnum::UtxoCoinVariant(maker_coin.clone()), - })); - assert!(validate_taker_payment_res.is_ok()); - - let error = block_on_f01(taker_coin.watcher_validate_taker_payment(WatcherValidatePaymentInput { - payment_tx: taker_payment.tx_hex(), - taker_payment_refund_preimage: taker_payment_refund_preimage.tx_hex(), - time_lock, - taker_pub: maker_pubkey.to_vec(), - maker_pub: maker_pubkey.to_vec(), - secret_hash: secret_hash.to_vec(), - wait_until: timeout, - confirmations: 1, - maker_coin: MmCoinEnum::UtxoCoinVariant(maker_coin.clone()), - })) - .unwrap_err() - .into_inner(); - - log!("error: {:?}", error); - match error { - ValidatePaymentError::WrongPaymentTx(err) => { - assert!(err.contains(INVALID_SENDER_ERR_LOG)) - }, - _ => panic!("Expected `WrongPaymentTx` {INVALID_SENDER_ERR_LOG}, found {:?}", error), - } - - // Used to get wrong swap id - let wrong_secret_hash = dhash160(&generate_secret().unwrap()); - let error = block_on_f01(taker_coin.watcher_validate_taker_payment(WatcherValidatePaymentInput { - payment_tx: taker_payment.tx_hex(), - taker_payment_refund_preimage: taker_payment_refund_preimage.tx_hex(), - time_lock, - taker_pub: taker_pubkey.to_vec(), - maker_pub: maker_pubkey.to_vec(), - secret_hash: wrong_secret_hash.to_vec(), - wait_until: timeout, - confirmations: 1, - maker_coin: MmCoinEnum::UtxoCoinVariant(maker_coin.clone()), + ), + } + + let mock_pubkey = taker_pubkey.to_vec(); + ::dex_pubkey.mock_safe(move |_| MockResult::Return(Box::leak(Box::new(mock_pubkey.clone())))); + + let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { + taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), + sender_pubkey: taker_pubkey.to_vec(), + min_block_number: 0, + lock_duration, })) .unwrap_err() .into_inner(); - log!("error: {:?}", error); match error { ValidatePaymentError::WrongPaymentTx(err) => { - assert!(err.contains(INVALID_SCRIPT_ERR_LOG)) + assert!(err.contains(INVALID_RECEIVER_ERR_LOG)) }, _ => panic!( - "Expected `WrongPaymentTx` {}, found {:?}", - INVALID_SCRIPT_ERR_LOG, error + "Expected `WrongPaymentTx` tx output script_pubkey doesn't match expected, found {:?}", + error ), } + ::dex_pubkey.clear_mock(); +} - let taker_payment_wrong_secret = block_on(taker_coin.send_taker_payment(SendPaymentArgs { - time_lock_duration, - time_lock, - other_pubkey: maker_pubkey, - secret_hash: wrong_secret_hash.as_slice(), - amount: BigDecimal::from(10), - swap_contract_address: &taker_coin.swap_contract_address(), - swap_unique_data: &[], - payment_instructions: &None, - watcher_reward: None, - wait_for_confirmation_until, - })) - .unwrap(); +#[test] +fn test_watcher_validate_taker_fee_erc20() { + let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run + let lock_duration = get_payment_locktime(); + + let taker_coin = erc20_coin_with_random_privkey(watchers_swap_contract()); + let taker_keypair = taker_coin.derive_htlc_key_pair(&[]); + let taker_pubkey = taker_keypair.public(); + + let taker_amount = MmNumber::from((1, 1)); + let dex_fee = DexFee::new_from_taker_coin(&taker_coin, "ETH", &taker_amount); + let taker_fee = block_on(taker_coin.send_taker_fee(dex_fee, Uuid::new_v4().as_bytes(), lock_duration)).unwrap(); let confirm_payment_input = ConfirmPaymentInput { - payment_tx: taker_payment_wrong_secret.tx_hex(), + payment_tx: taker_fee.tx_hex(), confirmations: 1, requires_nota: false, wait_until: timeout, @@ -1650,16 +517,20 @@ fn test_watcher_validate_taker_payment_utxo() { }; block_on_f01(taker_coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - let error = block_on_f01(taker_coin.watcher_validate_taker_payment(WatcherValidatePaymentInput { - payment_tx: taker_payment.tx_hex(), - taker_payment_refund_preimage: taker_payment_refund_preimage.tx_hex(), - time_lock: 500, - taker_pub: taker_pubkey.to_vec(), - maker_pub: maker_pubkey.to_vec(), - secret_hash: wrong_secret_hash.to_vec(), - wait_until: timeout, - confirmations: 1, - maker_coin: MmCoinEnum::UtxoCoinVariant(maker_coin.clone()), + let validate_taker_fee_res = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { + taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), + sender_pubkey: taker_pubkey.to_vec(), + min_block_number: 0, + lock_duration, + })); + assert!(validate_taker_fee_res.is_ok()); + + let wrong_keypair = key_pair_from_secret(&random_secp256k1_secret().take()).unwrap(); + let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { + taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), + sender_pubkey: wrong_keypair.public().to_vec(), + min_block_number: 0, + lock_duration, })) .unwrap_err() .into_inner(); @@ -1667,48 +538,52 @@ fn test_watcher_validate_taker_payment_utxo() { log!("error: {:?}", error); match error { ValidatePaymentError::WrongPaymentTx(err) => { - assert!(err.contains(INVALID_SCRIPT_ERR_LOG)) + assert!(err.contains(INVALID_SENDER_ERR_LOG)) + }, + _ => panic!("Expected `WrongPaymentTx` invalid public key, found {:?}", error), + } + + let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { + taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), + sender_pubkey: taker_pubkey.to_vec(), + min_block_number: u64::MAX, + lock_duration, + })) + .unwrap_err() + .into_inner(); + log!("error: {:?}", error); + match error { + ValidatePaymentError::WrongPaymentTx(err) => { + assert!(err.contains(EARLY_CONFIRMATION_ERR_LOG)) }, _ => panic!( - "Expected `WrongPaymentTx` {}, found {:?}", - INVALID_SCRIPT_ERR_LOG, error + "Expected `WrongPaymentTx` confirmed before min_block, found {:?}", + error ), } - let wrong_taker_payment_refund_preimage = block_on_f01(taker_coin.create_taker_payment_refund_preimage( - &taker_payment.tx_hex(), - time_lock, - maker_pubkey, - wrong_secret_hash.as_slice(), - &None, - &[], - )) - .unwrap(); + let mock_pubkey = taker_pubkey.to_vec(); + ::dex_pubkey.mock_safe(move |_| MockResult::Return(Box::leak(Box::new(mock_pubkey.clone())))); - let error = block_on_f01(taker_coin.watcher_validate_taker_payment(WatcherValidatePaymentInput { - payment_tx: taker_payment.tx_hex(), - taker_payment_refund_preimage: wrong_taker_payment_refund_preimage.tx_hex(), - time_lock, - taker_pub: taker_pubkey.to_vec(), - maker_pub: maker_pubkey.to_vec(), - secret_hash: secret_hash.to_vec(), - wait_until: timeout, - confirmations: 1, - maker_coin: MmCoinEnum::UtxoCoinVariant(maker_coin.clone()), + let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { + taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), + sender_pubkey: taker_pubkey.to_vec(), + min_block_number: 0, + lock_duration, })) .unwrap_err() .into_inner(); - log!("error: {:?}", error); match error { ValidatePaymentError::WrongPaymentTx(err) => { - assert!(err.contains(INVALID_REFUND_TX_ERR_LOG)) + assert!(err.contains(INVALID_RECEIVER_ERR_LOG)) }, _ => panic!( - "Expected `WrongPaymentTx` {}, found {:?}", - INVALID_REFUND_TX_ERR_LOG, error + "Expected `WrongPaymentTx` tx output script_pubkey doesn't match expected, found {:?}", + error ), } + ::dex_pubkey.clear_mock(); } #[test] @@ -2173,80 +1048,6 @@ fn test_watcher_validate_taker_payment_erc20() { } } -#[test] -fn test_taker_validates_taker_payment_refund_utxo() { - let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run - let time_lock_duration = get_payment_locktime(); - let wait_for_confirmation_until = wait_until_sec(time_lock_duration); - let time_lock = now_sec() - 10; - - let (_ctx, taker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); - let (_ctx, maker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); - let maker_pubkey = maker_coin.my_public_key().unwrap(); - - let secret_hash = dhash160(&generate_secret().unwrap()); - - let taker_payment = block_on(taker_coin.send_taker_payment(SendPaymentArgs { - time_lock_duration, - time_lock, - other_pubkey: maker_pubkey, - secret_hash: secret_hash.as_slice(), - amount: BigDecimal::from(10), - swap_contract_address: &None, - swap_unique_data: &[], - payment_instructions: &None, - watcher_reward: None, - wait_for_confirmation_until, - })) - .unwrap(); - - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: taker_payment.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(taker_coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - - let taker_payment_refund_preimage = block_on_f01(taker_coin.create_taker_payment_refund_preimage( - &taker_payment.tx_hex(), - time_lock, - maker_pubkey, - secret_hash.as_slice(), - &None, - &[], - )) - .unwrap(); - - let taker_payment_refund = block_on_f01(taker_coin.send_taker_payment_refund_preimage(RefundPaymentArgs { - payment_tx: &taker_payment_refund_preimage.tx_hex(), - other_pubkey: maker_pubkey, - tx_type_with_secret_hash: SwapTxTypeWithSecretHash::TakerOrMakerPayment { - maker_secret_hash: secret_hash.as_slice(), - }, - time_lock, - swap_contract_address: &None, - swap_unique_data: &[], - watcher_reward: false, - })) - .unwrap(); - - let validate_input = ValidateWatcherSpendInput { - payment_tx: taker_payment_refund.tx_hex(), - maker_pub: maker_pubkey.to_vec(), - swap_contract_address: None, - time_lock, - secret_hash: secret_hash.to_vec(), - amount: BigDecimal::from(10), - watcher_reward: None, - spend_type: WatcherSpendType::TakerPaymentRefund, - }; - - let validate_watcher_refund = block_on_f01(taker_coin.taker_validates_payment_spend_or_refund(validate_input)); - assert!(validate_watcher_refund.is_ok()); -} - #[test] fn test_taker_validates_taker_payment_refund_eth() { let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run @@ -2662,79 +1463,6 @@ fn test_taker_validates_taker_payment_refund_erc20() { } } -#[test] -fn test_taker_validates_maker_payment_spend_utxo() { - let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run - let time_lock_duration = get_payment_locktime(); - let wait_for_confirmation_until = wait_until_sec(time_lock_duration); - let time_lock = wait_for_confirmation_until; - - let (_ctx, taker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); - let (_ctx, maker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); - let taker_pubkey = taker_coin.my_public_key().unwrap(); - let maker_pubkey = maker_coin.my_public_key().unwrap(); - - let secret = generate_secret().unwrap(); - let secret_hash = dhash160(&secret); - - let maker_payment = block_on(maker_coin.send_maker_payment(SendPaymentArgs { - time_lock_duration, - time_lock, - other_pubkey: taker_pubkey, - secret_hash: secret_hash.as_slice(), - amount: BigDecimal::from(10), - swap_contract_address: &None, - swap_unique_data: &[], - payment_instructions: &None, - watcher_reward: None, - wait_for_confirmation_until, - })) - .unwrap(); - - block_on_f01(maker_coin.wait_for_confirmations(ConfirmPaymentInput { - payment_tx: maker_payment.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - })) - .unwrap(); - - let maker_payment_spend_preimage = block_on_f01(taker_coin.create_maker_payment_spend_preimage( - &maker_payment.tx_hex(), - time_lock, - maker_pubkey, - secret_hash.as_slice(), - &[], - )) - .unwrap(); - - let maker_payment_spend = block_on_f01(taker_coin.send_maker_payment_spend_preimage( - SendMakerPaymentSpendPreimageInput { - preimage: &maker_payment_spend_preimage.tx_hex(), - secret_hash: secret_hash.as_slice(), - secret: secret.as_slice(), - taker_pub: taker_pubkey, - watcher_reward: false, - }, - )) - .unwrap(); - - let validate_input = ValidateWatcherSpendInput { - payment_tx: maker_payment_spend.tx_hex(), - maker_pub: maker_pubkey.to_vec(), - swap_contract_address: None, - time_lock, - secret_hash: secret_hash.to_vec(), - amount: BigDecimal::from(10), - watcher_reward: None, - spend_type: WatcherSpendType::TakerPaymentRefund, - }; - - let validate_watcher_spend = block_on_f01(taker_coin.taker_validates_payment_spend_or_refund(validate_input)); - assert!(validate_watcher_spend.is_ok()); -} - #[test] fn test_taker_validates_maker_payment_spend_eth() { let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run @@ -3156,84 +1884,6 @@ fn test_taker_validates_maker_payment_spend_erc20() { }; } -#[test] -fn test_send_taker_payment_refund_preimage_utxo() { - let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run - let (_ctx, coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); - let my_public_key = coin.my_public_key().unwrap(); - - let time_lock = now_sec() - 3600; - let taker_payment_args = SendPaymentArgs { - time_lock_duration: 0, - time_lock, - other_pubkey: my_public_key, - secret_hash: &[0; 20], - amount: 1u64.into(), - swap_contract_address: &None, - swap_unique_data: &[], - payment_instructions: &None, - watcher_reward: None, - wait_for_confirmation_until: 0, - }; - let tx = block_on(coin.send_taker_payment(taker_payment_args)).unwrap(); - - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: tx.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - - let refund_tx = block_on_f01(coin.create_taker_payment_refund_preimage( - &tx.tx_hex(), - time_lock, - my_public_key, - &[0; 20], - &None, - &[], - )) - .unwrap(); - - let refund_tx = block_on_f01(coin.send_taker_payment_refund_preimage(RefundPaymentArgs { - payment_tx: &refund_tx.tx_hex(), - swap_contract_address: &None, - tx_type_with_secret_hash: SwapTxTypeWithSecretHash::TakerOrMakerPayment { - maker_secret_hash: &[0; 20], - }, - other_pubkey: my_public_key, - time_lock, - swap_unique_data: &[], - watcher_reward: false, - })) - .unwrap(); - - let confirm_payment_input = ConfirmPaymentInput { - payment_tx: refund_tx.tx_hex(), - confirmations: 1, - requires_nota: false, - wait_until: timeout, - check_every: 1, - }; - block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); - - let search_input = SearchForSwapTxSpendInput { - time_lock, - other_pub: coin.my_public_key().unwrap(), - secret_hash: &[0; 20], - tx: &tx.tx_hex(), - search_from_block: 0, - swap_contract_address: &None, - swap_unique_data: &[], - watcher_reward: false, - }; - let found = block_on(coin.search_for_swap_tx_spend_my(search_input)) - .unwrap() - .unwrap(); - assert_eq!(FoundSwapTxSpend::Refunded(refund_tx), found); -} - #[test] fn test_watcher_reward() { let timeout = wait_until_sec(300); // timeout if test takes more than 300 seconds to run diff --git a/mm2src/mm2_main/tests/docker_tests/swap_watcher_tests/mod.rs b/mm2src/mm2_main/tests/docker_tests/swap_watcher_tests/mod.rs new file mode 100644 index 0000000000..f18f401f48 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/swap_watcher_tests/mod.rs @@ -0,0 +1,460 @@ +//! Swap Watcher Tests +//! +//! Shared helpers for watcher tests. UTXO tests are always enabled, +//! ETH/ERC20 tests require the `docker-tests-watchers-eth` feature. + +// UTXO watcher tests - always enabled with docker-tests-watchers +mod utxo; + +// ETH/ERC20 watcher tests - disabled by default (unstable, not completed yet) +#[cfg(feature = "docker-tests-watchers-eth")] +mod eth; + +// Common imports (used by UTXO watcher tests) +use crate::docker_tests::helpers::env::random_secp256k1_secret; +use crate::docker_tests::helpers::utxo::{generate_utxo_coin_with_privkey, generate_utxo_coin_with_random_privkey}; +use crate::integration_tests_common::*; +use coins::coin_errors::ValidatePaymentError; +use coins::utxo::utxo_standard::UtxoStandardCoin; +use coins::utxo::{dhash160, UtxoCommonOps}; +use coins::{ + ConfirmPaymentInput, DexFee, FoundSwapTxSpend, MarketCoinOps, MmCoin, MmCoinEnum, RefundPaymentArgs, + SearchForSwapTxSpendInput, SendMakerPaymentSpendPreimageInput, SendPaymentArgs, SwapOps, SwapTxTypeWithSecretHash, + ValidateWatcherSpendInput, WatcherOps, WatcherSpendType, WatcherValidatePaymentInput, WatcherValidateTakerFeeInput, + EARLY_CONFIRMATION_ERR_LOG, INVALID_RECEIVER_ERR_LOG, INVALID_REFUND_TX_ERR_LOG, INVALID_SCRIPT_ERR_LOG, + INVALID_SENDER_ERR_LOG, OLD_TRANSACTION_ERR_LOG, +}; +use common::{block_on, block_on_f01, now_sec, wait_until_sec}; +use mm2_main::lp_swap::{ + generate_secret, get_payment_locktime, MAKER_PAYMENT_SENT_LOG, MAKER_PAYMENT_SPEND_FOUND_LOG, + MAKER_PAYMENT_SPEND_SENT_LOG, REFUND_TEST_FAILURE_LOG, TAKER_PAYMENT_REFUND_SENT_LOG, WATCHER_MESSAGE_SENT_LOG, +}; +use mm2_number::BigDecimal; +use mm2_number::MmNumber; +use mm2_test_helpers::for_tests::{ + mm_dump, my_balance, my_swap_status, mycoin1_conf, mycoin_conf, start_swaps, + wait_for_swaps_finish_and_check_status, MarketMakerIt, Mm2TestConf, DEFAULT_RPC_PASSWORD, +}; +use mm2_test_helpers::structs::WatcherConf; +use mocktopus::mocking::*; +use num_traits::Zero; +use serde_json::json; + +// ETH-only imports (used only by ETH watcher tests) +#[cfg(feature = "docker-tests-watchers-eth")] +use crate::docker_tests::helpers::eth::{ + erc20_coin_with_random_privkey, erc20_contract_checksum, eth_coin_with_random_privkey, watchers_swap_contract, + watchers_swap_contract_checksum, GETH_RPC_URL, +}; +#[cfg(feature = "docker-tests-watchers-eth")] +use coins::eth::EthCoin; +#[cfg(feature = "docker-tests-watchers-eth")] +use coins::{ + RewardTarget, TestCoin, INVALID_CONTRACT_ADDRESS_ERR_LOG, INVALID_PAYMENT_STATE_ERR_LOG, INVALID_SWAP_ID_ERR_LOG, +}; +#[cfg(feature = "docker-tests-watchers-eth")] +use crypto::privkey::{key_pair_from_secret, key_pair_from_seed}; +#[cfg(feature = "docker-tests-watchers-eth")] +use mm2_test_helpers::for_tests::{enable_eth_coin, erc20_dev_conf, eth_dev_conf, eth_jst_testnet_conf}; +#[cfg(feature = "docker-tests-watchers-eth")] +use mm2_test_helpers::get_passphrase; +#[cfg(feature = "docker-tests-watchers-eth")] +use num_traits::One; +use primitives::hash::H256; +use serde_json::Value; +use std::str::FromStr; +use std::thread; +use std::time::Duration; +use uuid::Uuid; + +#[derive(Debug, Clone)] +struct BalanceResult { + alice_acoin_balance_before: BigDecimal, + #[cfg(feature = "docker-tests-watchers-eth")] + alice_acoin_balance_middle: BigDecimal, + alice_acoin_balance_after: BigDecimal, + alice_bcoin_balance_before: BigDecimal, + #[cfg(feature = "docker-tests-watchers-eth")] + alice_bcoin_balance_middle: BigDecimal, + alice_bcoin_balance_after: BigDecimal, + #[cfg(feature = "docker-tests-watchers-eth")] + alice_eth_balance_middle: BigDecimal, + #[cfg(feature = "docker-tests-watchers-eth")] + alice_eth_balance_after: BigDecimal, + bob_acoin_balance_before: BigDecimal, + bob_acoin_balance_after: BigDecimal, + bob_bcoin_balance_before: BigDecimal, + bob_bcoin_balance_after: BigDecimal, + #[cfg(feature = "docker-tests-watchers-eth")] + watcher_acoin_balance_before: BigDecimal, + #[cfg(feature = "docker-tests-watchers-eth")] + watcher_acoin_balance_after: BigDecimal, + #[cfg(feature = "docker-tests-watchers-eth")] + watcher_bcoin_balance_before: BigDecimal, + #[cfg(feature = "docker-tests-watchers-eth")] + watcher_bcoin_balance_after: BigDecimal, +} + +fn enable_coin(mm_node: &MarketMakerIt, coin: &str) { + if coin == "MYCOIN" || coin == "MYCOIN1" { + log!("{:?}", block_on(enable_native(mm_node, coin, &[], None))); + } else { + #[cfg(feature = "docker-tests-watchers-eth")] + enable_eth(mm_node, coin); + #[cfg(not(feature = "docker-tests-watchers-eth"))] + panic!("ETH coin {} requires docker-tests-watchers-eth feature", coin); + } +} + +#[cfg(feature = "docker-tests-watchers-eth")] +fn enable_eth(mm_node: &MarketMakerIt, coin: &str) { + dbg!(block_on(enable_eth_coin( + mm_node, + coin, + &[GETH_RPC_URL], + &watchers_swap_contract_checksum(), + Some(&watchers_swap_contract_checksum()), + true + ))); +} + +#[allow(clippy::enum_variant_names)] +enum SwapFlow { + WatcherSpendsMakerPayment, + WatcherRefundsTakerPayment, + TakerSpendsMakerPayment, +} + +#[allow(clippy::too_many_arguments)] +fn start_swaps_and_get_balances( + a_coin: &'static str, + b_coin: &'static str, + maker_price: f64, + taker_price: f64, + volume: f64, + envs: &[(&str, &str)], + swap_flow: SwapFlow, + alice_privkey: &str, + bob_privkey: &str, + watcher_privkey: &str, + custom_locktime: Option, +) -> BalanceResult { + #[cfg(feature = "docker-tests-watchers-eth")] + let coins = json!([ + eth_dev_conf(), + erc20_dev_conf(&erc20_contract_checksum()), + mycoin_conf(1000), + mycoin1_conf(1000) + ]); + #[cfg(not(feature = "docker-tests-watchers-eth"))] + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + + let mut alice_conf = Mm2TestConf::seednode(&format!("0x{alice_privkey}"), &coins); + if let Some(locktime) = custom_locktime { + alice_conf.conf["payment_locktime"] = locktime.into(); + } + let mut mm_alice = block_on(MarketMakerIt::start_with_envs( + alice_conf.conf.clone(), + alice_conf.rpc_password.clone(), + None, + envs, + )) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); + log!("Alice log path: {}", mm_alice.log_path.display()); + + let mut bob_conf = Mm2TestConf::light_node(&format!("0x{bob_privkey}"), &coins, &[&mm_alice.ip.to_string()]); + if let Some(locktime) = custom_locktime { + bob_conf.conf["payment_locktime"] = locktime.into(); + } + let mut mm_bob = block_on(MarketMakerIt::start_with_envs( + bob_conf.conf.clone(), + bob_conf.rpc_password, + None, + envs, + )) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_bob.mm_dump(); + log!("Bob log path: {}", mm_bob.log_path.display()); + + generate_utxo_coin_with_privkey("MYCOIN", 100.into(), H256::from_str(bob_privkey).unwrap()); + generate_utxo_coin_with_privkey("MYCOIN", 100.into(), H256::from_str(alice_privkey).unwrap()); + generate_utxo_coin_with_privkey("MYCOIN1", 100.into(), H256::from_str(bob_privkey).unwrap()); + generate_utxo_coin_with_privkey("MYCOIN1", 100.into(), H256::from_str(alice_privkey).unwrap()); + + let (watcher_conf, watcher_log_to_wait) = match swap_flow { + SwapFlow::WatcherSpendsMakerPayment => ( + WatcherConf { + wait_taker_payment: 0., + wait_maker_payment_spend_factor: 0., + refund_start_factor: 1.5, + search_interval: 1.0, + }, + MAKER_PAYMENT_SPEND_SENT_LOG, + ), + SwapFlow::WatcherRefundsTakerPayment => ( + WatcherConf { + wait_taker_payment: 0., + wait_maker_payment_spend_factor: 1., + refund_start_factor: 0., + search_interval: 1., + }, + TAKER_PAYMENT_REFUND_SENT_LOG, + ), + SwapFlow::TakerSpendsMakerPayment => ( + WatcherConf { + wait_taker_payment: 0., + wait_maker_payment_spend_factor: 1., + refund_start_factor: 1.5, + search_interval: 1.0, + }, + MAKER_PAYMENT_SPEND_FOUND_LOG, + ), + }; + + let mut watcher_conf = Mm2TestConf::watcher_light_node( + &format!("0x{watcher_privkey}"), + &coins, + &[&mm_alice.ip.to_string()], + watcher_conf, + ) + .conf; + if let Some(locktime) = custom_locktime { + watcher_conf["payment_locktime"] = locktime.into(); + } + + let mut mm_watcher = block_on(MarketMakerIt::start_with_envs( + watcher_conf, + DEFAULT_RPC_PASSWORD.to_string(), + None, + envs, + )) + .unwrap(); + let (_watcher_dump_log, _watcher_dump_dashboard) = mm_dump(&mm_watcher.log_path); + log!("Watcher log path: {}", mm_watcher.log_path.display()); + + enable_coin(&mm_alice, a_coin); + enable_coin(&mm_alice, b_coin); + enable_coin(&mm_bob, a_coin); + enable_coin(&mm_bob, b_coin); + enable_coin(&mm_watcher, a_coin); + enable_coin(&mm_watcher, b_coin); + + #[cfg(feature = "docker-tests-watchers-eth")] + if a_coin != "ETH" && b_coin != "ETH" { + enable_coin(&mm_alice, "ETH"); + } + + let alice_acoin_balance_before = block_on(my_balance(&mm_alice, a_coin)).balance; + let alice_bcoin_balance_before = block_on(my_balance(&mm_alice, b_coin)).balance; + let bob_acoin_balance_before = block_on(my_balance(&mm_bob, a_coin)).balance; + let bob_bcoin_balance_before = block_on(my_balance(&mm_bob, b_coin)).balance; + #[cfg(feature = "docker-tests-watchers-eth")] + let watcher_acoin_balance_before = block_on(my_balance(&mm_watcher, a_coin)).balance; + #[cfg(feature = "docker-tests-watchers-eth")] + let watcher_bcoin_balance_before = block_on(my_balance(&mm_watcher, b_coin)).balance; + + #[cfg(feature = "docker-tests-watchers-eth")] + let mut alice_acoin_balance_middle = BigDecimal::zero(); + #[cfg(feature = "docker-tests-watchers-eth")] + let mut alice_bcoin_balance_middle = BigDecimal::zero(); + #[cfg(feature = "docker-tests-watchers-eth")] + let mut alice_eth_balance_middle = BigDecimal::zero(); + let mut bob_acoin_balance_after = BigDecimal::zero(); + let mut bob_bcoin_balance_after = BigDecimal::zero(); + + block_on(start_swaps( + &mut mm_bob, + &mut mm_alice, + &[(b_coin, a_coin)], + maker_price, + taker_price, + volume, + )); + + if matches!(swap_flow, SwapFlow::WatcherRefundsTakerPayment) { + block_on(mm_bob.wait_for_log(120., |log| log.contains(MAKER_PAYMENT_SENT_LOG))).unwrap(); + block_on(mm_bob.stop()).unwrap(); + } + if !matches!(swap_flow, SwapFlow::TakerSpendsMakerPayment) { + block_on(mm_alice.wait_for_log(120., |log| log.contains(WATCHER_MESSAGE_SENT_LOG))).unwrap(); + #[cfg(feature = "docker-tests-watchers-eth")] + { + alice_acoin_balance_middle = block_on(my_balance(&mm_alice, a_coin)).balance; + alice_bcoin_balance_middle = block_on(my_balance(&mm_alice, b_coin)).balance; + alice_eth_balance_middle = block_on(my_balance(&mm_alice, "ETH")).balance; + } + block_on(mm_alice.stop()).unwrap(); + } + + block_on(mm_watcher.wait_for_log(120., |log| log.contains(watcher_log_to_wait))).unwrap(); + thread::sleep(Duration::from_secs(20)); + + let mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); + enable_coin(&mm_alice, a_coin); + enable_coin(&mm_alice, b_coin); + + #[cfg(feature = "docker-tests-watchers-eth")] + if a_coin != "ETH" && b_coin != "ETH" { + enable_coin(&mm_alice, "ETH"); + } + + let alice_acoin_balance_after = block_on(my_balance(&mm_alice, a_coin)).balance; + let alice_bcoin_balance_after = block_on(my_balance(&mm_alice, b_coin)).balance; + #[cfg(feature = "docker-tests-watchers-eth")] + let alice_eth_balance_after = block_on(my_balance(&mm_alice, "ETH")).balance; + if !matches!(swap_flow, SwapFlow::WatcherRefundsTakerPayment) { + bob_acoin_balance_after = block_on(my_balance(&mm_bob, a_coin)).balance; + bob_bcoin_balance_after = block_on(my_balance(&mm_bob, b_coin)).balance; + } + #[cfg(feature = "docker-tests-watchers-eth")] + let watcher_acoin_balance_after = block_on(my_balance(&mm_watcher, a_coin)).balance; + #[cfg(feature = "docker-tests-watchers-eth")] + let watcher_bcoin_balance_after = block_on(my_balance(&mm_watcher, b_coin)).balance; + + BalanceResult { + alice_acoin_balance_before, + #[cfg(feature = "docker-tests-watchers-eth")] + alice_acoin_balance_middle, + alice_acoin_balance_after, + alice_bcoin_balance_before, + #[cfg(feature = "docker-tests-watchers-eth")] + alice_bcoin_balance_middle, + alice_bcoin_balance_after, + #[cfg(feature = "docker-tests-watchers-eth")] + alice_eth_balance_middle, + #[cfg(feature = "docker-tests-watchers-eth")] + alice_eth_balance_after, + bob_acoin_balance_before, + bob_acoin_balance_after, + bob_bcoin_balance_before, + bob_bcoin_balance_after, + #[cfg(feature = "docker-tests-watchers-eth")] + watcher_acoin_balance_before, + #[cfg(feature = "docker-tests-watchers-eth")] + watcher_acoin_balance_after, + #[cfg(feature = "docker-tests-watchers-eth")] + watcher_bcoin_balance_before, + #[cfg(feature = "docker-tests-watchers-eth")] + watcher_bcoin_balance_after, + } +} + +fn check_actual_events(mm_alice: &MarketMakerIt, uuid: &str, expected_events: &[&'static str]) -> Value { + let status_response = block_on(my_swap_status(mm_alice, uuid)).unwrap(); + let events_array = status_response["result"]["events"].as_array().unwrap(); + let actual_events = events_array.iter().map(|item| item["event"]["type"].as_str().unwrap()); + let actual_events: Vec<&str> = actual_events.collect(); + assert_eq!(expected_events, actual_events.as_slice()); + status_response +} + +fn run_taker_node( + coins: &Value, + envs: &[(&str, &str)], + seednodes: &[&str], + custom_locktime: Option, +) -> (MarketMakerIt, Mm2TestConf) { + let privkey = hex::encode(random_secp256k1_secret()); + let mut conf = Mm2TestConf::light_node(&format!("0x{privkey}"), coins, seednodes); + if let Some(locktime) = custom_locktime { + conf.conf["payment_locktime"] = locktime.into(); + } + let mm = block_on(MarketMakerIt::start_with_envs( + conf.conf.clone(), + conf.rpc_password.clone(), + None, + envs, + )) + .unwrap(); + let (_dump_log, _dump_dashboard) = mm.mm_dump(); + log!("Log path: {}", mm.log_path.display()); + + generate_utxo_coin_with_privkey("MYCOIN", 100.into(), H256::from_str(&privkey).unwrap()); + generate_utxo_coin_with_privkey("MYCOIN1", 100.into(), H256::from_str(&privkey).unwrap()); + enable_coin(&mm, "MYCOIN"); + enable_coin(&mm, "MYCOIN1"); + + (mm, conf) +} + +fn restart_taker_and_wait_until(conf: &Mm2TestConf, envs: &[(&str, &str)], wait_until: &str) -> MarketMakerIt { + let mut mm_alice = block_on(MarketMakerIt::start_with_envs( + conf.conf.clone(), + conf.rpc_password.clone(), + None, + envs, + )) + .unwrap(); + + let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); + log!("Alice log path: {}", mm_alice.log_path.display()); + enable_coin(&mm_alice, "MYCOIN"); + enable_coin(&mm_alice, "MYCOIN1"); + + block_on(mm_alice.wait_for_log(120., |log| log.contains(wait_until))).unwrap(); + mm_alice +} + +fn run_maker_node( + coins: &Value, + envs: &[(&str, &str)], + seednodes: &[&str], + custom_locktime: Option, +) -> MarketMakerIt { + let privkey = hex::encode(random_secp256k1_secret()); + let mut conf = if seednodes.is_empty() { + Mm2TestConf::seednode(&format!("0x{privkey}"), coins) + } else { + Mm2TestConf::light_node(&format!("0x{privkey}"), coins, seednodes) + }; + if let Some(locktime) = custom_locktime { + conf.conf["payment_locktime"] = locktime.into(); + } + let mm = block_on(MarketMakerIt::start_with_envs( + conf.conf.clone(), + conf.rpc_password, + None, + envs, + )) + .unwrap(); + let (_dump_log, _dump_dashboard) = mm.mm_dump(); + log!("Log path: {}", mm.log_path.display()); + + generate_utxo_coin_with_privkey("MYCOIN", 100.into(), H256::from_str(&privkey).unwrap()); + generate_utxo_coin_with_privkey("MYCOIN1", 100.into(), H256::from_str(&privkey).unwrap()); + enable_coin(&mm, "MYCOIN"); + enable_coin(&mm, "MYCOIN1"); + + mm +} + +fn run_watcher_node( + coins: &Value, + envs: &[(&str, &str)], + seednodes: &[&str], + watcher_conf: WatcherConf, + custom_locktime: Option, +) -> MarketMakerIt { + let privkey = hex::encode(random_secp256k1_secret()); + let mut conf = Mm2TestConf::watcher_light_node(&format!("0x{privkey}"), coins, seednodes, watcher_conf).conf; + if let Some(locktime) = custom_locktime { + conf["payment_locktime"] = locktime.into(); + } + let mm = block_on(MarketMakerIt::start_with_envs( + conf, + DEFAULT_RPC_PASSWORD.to_string(), + None, + envs, + )) + .unwrap(); + let (_dump_log, _dump_dashboard) = mm.mm_dump(); + log!("Log path: {}", mm.log_path.display()); + + generate_utxo_coin_with_privkey("MYCOIN", 100.into(), H256::from_str(&privkey).unwrap()); + generate_utxo_coin_with_privkey("MYCOIN1", 100.into(), H256::from_str(&privkey).unwrap()); + enable_coin(&mm, "MYCOIN"); + enable_coin(&mm, "MYCOIN1"); + + mm +} diff --git a/mm2src/mm2_main/tests/docker_tests/swap_watcher_tests/utxo.rs b/mm2src/mm2_main/tests/docker_tests/swap_watcher_tests/utxo.rs new file mode 100644 index 0000000000..6c641e1067 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/swap_watcher_tests/utxo.rs @@ -0,0 +1,971 @@ +//! UTXO-only Watcher Tests +//! +//! Tests for watcher node functionality with UTXO coins only. + +use super::*; + +#[test] +fn test_taker_saves_the_swap_as_successful_after_restart_panic_at_wait_for_taker_payment_spend() { + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut mm_bob = run_maker_node(&coins, &[], &[], None); + let (mut mm_alice, mut alice_conf) = run_taker_node( + &coins, + &[("TAKER_FAIL_AT", "wait_for_taker_payment_spend_panic")], + &[&mm_bob.ip.to_string()], + None, + ); + + let watcher_conf = WatcherConf { + wait_taker_payment: 0., + wait_maker_payment_spend_factor: 0., + refund_start_factor: 1.5, + search_interval: 1.0, + }; + let mut mm_watcher = run_watcher_node(&coins, &[], &[&mm_bob.ip.to_string()], watcher_conf, None); + + let uuids = block_on(start_swaps( + &mut mm_bob, + &mut mm_alice, + &[("MYCOIN1", "MYCOIN")], + 25., + 25., + 2., + )); + let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); + log!("Alice log path: {}", mm_alice.log_path.display()); + alice_conf.conf["dbdir"] = mm_alice.folder.join("DB").to_str().unwrap().into(); + + block_on(mm_alice.wait_for_log(120., |log| log.contains(WATCHER_MESSAGE_SENT_LOG))).unwrap(); + block_on(mm_bob.wait_for_log(120., |log| log.contains(&format!("[swap uuid={}] Finished", &uuids[0])))).unwrap(); + block_on(mm_watcher.wait_for_log(120., |log| log.contains(MAKER_PAYMENT_SPEND_SENT_LOG))).unwrap(); + + block_on(mm_alice.stop()).unwrap(); + + let mm_alice = restart_taker_and_wait_until(&alice_conf, &[], &format!("[swap uuid={}] Finished", &uuids[0])); + + let expected_events = [ + "Started", + "Negotiated", + "TakerFeeSent", + "TakerPaymentInstructionsReceived", + "MakerPaymentReceived", + "MakerPaymentWaitConfirmStarted", + "MakerPaymentValidatedAndConfirmed", + "TakerPaymentSent", + "WatcherMessageSent", + "TakerPaymentSpent", + "MakerPaymentSpentByWatcher", + "MakerPaymentSpendConfirmed", + "Finished", + ]; + check_actual_events(&mm_alice, &uuids[0], &expected_events); + + block_on(mm_alice.stop()).unwrap(); + block_on(mm_watcher.stop()).unwrap(); + block_on(mm_bob.stop()).unwrap(); +} + +#[test] +fn test_taker_saves_the_swap_as_successful_after_restart_panic_at_maker_payment_spend() { + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut mm_bob = run_maker_node(&coins, &[], &[], None); + let (mut mm_alice, mut alice_conf) = run_taker_node( + &coins, + &[("TAKER_FAIL_AT", "maker_payment_spend_panic")], + &[&mm_bob.ip.to_string()], + None, + ); + + let watcher_conf = WatcherConf { + wait_taker_payment: 0., + wait_maker_payment_spend_factor: 0., + refund_start_factor: 1.5, + search_interval: 1.0, + }; + let mut mm_watcher = run_watcher_node(&coins, &[], &[&mm_bob.ip.to_string()], watcher_conf, None); + + let uuids = block_on(start_swaps( + &mut mm_bob, + &mut mm_alice, + &[("MYCOIN1", "MYCOIN")], + 25., + 25., + 2., + )); + let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); + log!("Alice log path: {}", mm_alice.log_path.display()); + alice_conf.conf["dbdir"] = mm_alice.folder.join("DB").to_str().unwrap().into(); + + block_on(mm_alice.wait_for_log(120., |log| log.contains(WATCHER_MESSAGE_SENT_LOG))).unwrap(); + block_on(mm_bob.wait_for_log(120., |log| log.contains(&format!("[swap uuid={}] Finished", &uuids[0])))).unwrap(); + block_on(mm_watcher.wait_for_log(120., |log| log.contains(MAKER_PAYMENT_SPEND_SENT_LOG))).unwrap(); + + block_on(mm_alice.stop()).unwrap(); + + let mm_alice = restart_taker_and_wait_until(&alice_conf, &[], &format!("[swap uuid={}] Finished", &uuids[0])); + + let expected_events = [ + "Started", + "Negotiated", + "TakerFeeSent", + "TakerPaymentInstructionsReceived", + "MakerPaymentReceived", + "MakerPaymentWaitConfirmStarted", + "MakerPaymentValidatedAndConfirmed", + "TakerPaymentSent", + "WatcherMessageSent", + "TakerPaymentSpent", + "MakerPaymentSpentByWatcher", + "MakerPaymentSpendConfirmed", + "Finished", + ]; + check_actual_events(&mm_alice, &uuids[0], &expected_events); +} + +#[test] +fn test_taker_saves_the_swap_as_finished_after_restart_taker_payment_refunded_panic_at_wait_for_taker_payment_spend() { + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mm_seednode = run_maker_node(&coins, &[], &[], Some(60)); + let mut mm_bob = run_maker_node(&coins, &[], &[&mm_seednode.ip.to_string()], Some(60)); + let (mut mm_alice, mut alice_conf) = run_taker_node( + &coins, + &[("TAKER_FAIL_AT", "wait_for_taker_payment_spend_panic")], + &[&mm_seednode.ip.to_string()], + Some(60), + ); + + let watcher_conf = WatcherConf { + wait_taker_payment: 0., + wait_maker_payment_spend_factor: 1., + refund_start_factor: 0., + search_interval: 1., + }; + let mut mm_watcher = run_watcher_node(&coins, &[], &[&mm_seednode.ip.to_string()], watcher_conf, Some(60)); + + let uuids = block_on(start_swaps( + &mut mm_bob, + &mut mm_alice, + &[("MYCOIN1", "MYCOIN")], + 25., + 25., + 2., + )); + let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); + log!("Alice log path: {}", mm_alice.log_path.display()); + alice_conf.conf["dbdir"] = mm_alice.folder.join("DB").to_str().unwrap().into(); + + block_on(mm_bob.wait_for_log(120., |log| log.contains(MAKER_PAYMENT_SENT_LOG))).unwrap(); + block_on(mm_bob.stop()).unwrap(); + + block_on(mm_alice.wait_for_log(120., |log| log.contains(WATCHER_MESSAGE_SENT_LOG))).unwrap(); + block_on(mm_watcher.wait_for_log(120., |log| log.contains(TAKER_PAYMENT_REFUND_SENT_LOG))).unwrap(); + + block_on(mm_alice.stop()).unwrap(); + + let mm_alice = restart_taker_and_wait_until(&alice_conf, &[], &format!("[swap uuid={}] Finished", &uuids[0])); + + let expected_events = [ + "Started", + "Negotiated", + "TakerFeeSent", + "TakerPaymentInstructionsReceived", + "MakerPaymentReceived", + "MakerPaymentWaitConfirmStarted", + "MakerPaymentValidatedAndConfirmed", + "TakerPaymentSent", + "WatcherMessageSent", + "TakerPaymentRefundedByWatcher", + "Finished", + ]; + check_actual_events(&mm_alice, &uuids[0], &expected_events); + + block_on(mm_alice.stop()).unwrap(); + block_on(mm_watcher.stop()).unwrap(); + block_on(mm_seednode.stop()).unwrap(); +} + +#[test] +fn test_taker_saves_the_swap_as_finished_after_restart_taker_payment_refunded_panic_at_taker_payment_refund() { + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mm_seednode = run_maker_node(&coins, &[], &[], Some(60)); + let mut mm_bob = run_maker_node(&coins, &[], &[&mm_seednode.ip.to_string()], Some(60)); + let (mut mm_alice, mut alice_conf) = run_taker_node( + &coins, + &[("TAKER_FAIL_AT", "taker_payment_refund_panic")], + &[&mm_seednode.ip.to_string()], + Some(60), + ); + + let watcher_conf = WatcherConf { + wait_taker_payment: 0., + wait_maker_payment_spend_factor: 1., + refund_start_factor: 0., + search_interval: 1., + }; + let mut mm_watcher = run_watcher_node(&coins, &[], &[&mm_seednode.ip.to_string()], watcher_conf, Some(60)); + + let uuids = block_on(start_swaps( + &mut mm_bob, + &mut mm_alice, + &[("MYCOIN1", "MYCOIN")], + 25., + 25., + 2., + )); + let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); + log!("Alice log path: {}", mm_alice.log_path.display()); + alice_conf.conf["dbdir"] = mm_alice.folder.join("DB").to_str().unwrap().into(); + + block_on(mm_bob.wait_for_log(120., |log| log.contains(MAKER_PAYMENT_SENT_LOG))).unwrap(); + block_on(mm_bob.stop()).unwrap(); + + block_on(mm_alice.wait_for_log(120., |log| log.contains(REFUND_TEST_FAILURE_LOG))).unwrap(); + block_on(mm_watcher.wait_for_log(120., |log| log.contains(TAKER_PAYMENT_REFUND_SENT_LOG))).unwrap(); + + block_on(mm_alice.stop()).unwrap(); + + let mm_alice = restart_taker_and_wait_until(&alice_conf, &[], &format!("[swap uuid={}] Finished", &uuids[0])); + + let expected_events = [ + "Started", + "Negotiated", + "TakerFeeSent", + "TakerPaymentInstructionsReceived", + "MakerPaymentReceived", + "MakerPaymentWaitConfirmStarted", + "MakerPaymentValidatedAndConfirmed", + "TakerPaymentSent", + "WatcherMessageSent", + "TakerPaymentWaitForSpendFailed", + "TakerPaymentWaitRefundStarted", + "TakerPaymentRefundStarted", + "TakerPaymentRefundedByWatcher", + "Finished", + ]; + check_actual_events(&mm_alice, &uuids[0], &expected_events); + + block_on(mm_alice.stop()).unwrap(); + block_on(mm_watcher.stop()).unwrap(); + block_on(mm_seednode.stop()).unwrap(); +} + +#[test] +fn test_taker_completes_swap_after_restart() { + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut mm_bob = run_maker_node(&coins, &[], &[], None); + let (mut mm_alice, mut alice_conf) = run_taker_node(&coins, &[], &[&mm_bob.ip.to_string()], None); + + let uuids = block_on(start_swaps( + &mut mm_bob, + &mut mm_alice, + &[("MYCOIN1", "MYCOIN")], + 25., + 25., + 2., + )); + + block_on(mm_alice.wait_for_log(120., |log| log.contains(WATCHER_MESSAGE_SENT_LOG))).unwrap(); + alice_conf.conf["dbdir"] = mm_alice.folder.join("DB").to_str().unwrap().into(); + block_on(mm_alice.stop()).unwrap(); + + let mut mm_alice = block_on(MarketMakerIt::start_with_envs( + alice_conf.conf, + alice_conf.rpc_password.clone(), + None, + &[], + )) + .unwrap(); + + let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); + log!("Alice log path: {}", mm_alice.log_path.display()); + enable_coin(&mm_alice, "MYCOIN"); + enable_coin(&mm_alice, "MYCOIN1"); + + block_on(wait_for_swaps_finish_and_check_status( + &mut mm_bob, + &mut mm_alice, + &uuids, + 2., + 25., + )); + + block_on(mm_alice.stop()).unwrap(); + block_on(mm_bob.stop()).unwrap(); +} + +// Verifies https://github.com/KomodoPlatform/komodo-defi-framework/issues/2111 +#[test] +fn test_taker_completes_swap_after_taker_payment_spent_while_offline() { + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut mm_bob = run_maker_node(&coins, &[], &[], None); + let (mut mm_alice, mut alice_conf) = run_taker_node(&coins, &[], &[&mm_bob.ip.to_string()], None); + + let uuids = block_on(start_swaps( + &mut mm_bob, + &mut mm_alice, + &[("MYCOIN1", "MYCOIN")], + 25., + 25., + 2., + )); + + // stop taker after taker payment sent + let taker_payment_msg = "Taker payment tx hash "; + block_on(mm_alice.wait_for_log(120., |log| log.contains(taker_payment_msg))).unwrap(); + // ensure p2p message is sent to the maker, this happens before this message: + block_on(mm_alice.wait_for_log(120., |log| log.contains("Waiting for maker to spend taker payment!"))).unwrap(); + alice_conf.conf["dbdir"] = mm_alice.folder.join("DB").to_str().unwrap().into(); + block_on(mm_alice.stop()).unwrap(); + + // wait for taker payment spent by maker + block_on(mm_bob.wait_for_log(120., |log| log.contains("Taker payment spend tx"))).unwrap(); + // and restart taker + let mut mm_alice = block_on(MarketMakerIt::start_with_envs( + alice_conf.conf, + alice_conf.rpc_password.clone(), + None, + &[], + )) + .unwrap(); + + let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); + log!("Alice log path: {}", mm_alice.log_path.display()); + enable_coin(&mm_alice, "MYCOIN"); + enable_coin(&mm_alice, "MYCOIN1"); + + block_on(wait_for_swaps_finish_and_check_status( + &mut mm_bob, + &mut mm_alice, + &uuids, + 2., + 25., + )); + + block_on(mm_alice.stop()).unwrap(); + block_on(mm_bob.stop()).unwrap(); +} + +#[test] +fn test_watcher_spends_maker_payment_utxo_utxo() { + let alice_privkey = hex::encode(random_secp256k1_secret()); + let bob_privkey = hex::encode(random_secp256k1_secret()); + let watcher_privkey = hex::encode(random_secp256k1_secret()); + + let balances = start_swaps_and_get_balances( + "MYCOIN", + "MYCOIN1", + 25., + 25., + 2., + &[], + SwapFlow::WatcherSpendsMakerPayment, + &alice_privkey, + &bob_privkey, + &watcher_privkey, + None, + ); + + let acoin_volume = BigDecimal::from_str("50").unwrap(); + let bcoin_volume = BigDecimal::from_str("2").unwrap(); + // DEX fee is 2% of taker volume (acoin_volume), paid by Alice (taker) + let dex_fee = &acoin_volume * BigDecimal::from_str("0.02").unwrap(); + + // Alice spends acoin_volume + dex_fee (as taker, she pays the DEX fee in the taker coin) + assert_eq!( + balances.alice_acoin_balance_after.round(0), + balances.alice_acoin_balance_before.clone() - acoin_volume.clone() - dex_fee + ); + assert_eq!( + balances.alice_bcoin_balance_after.round(0), + balances.alice_bcoin_balance_before + bcoin_volume.clone() + ); + // Bob receives acoin_volume (no fee on his side) + assert_eq!( + balances.bob_acoin_balance_after.round(0), + balances.bob_acoin_balance_before + acoin_volume + ); + assert_eq!( + balances.bob_bcoin_balance_after.round(0), + balances.bob_bcoin_balance_before - bcoin_volume + ); +} + +#[test] +fn test_watcher_refunds_taker_payment_utxo() { + let alice_privkey = &hex::encode(random_secp256k1_secret()); + let bob_privkey = &hex::encode(random_secp256k1_secret()); + let watcher_privkey = &hex::encode(random_secp256k1_secret()); + + let balances = start_swaps_and_get_balances( + "MYCOIN1", + "MYCOIN", + 25., + 25., + 2., + &[], + SwapFlow::WatcherRefundsTakerPayment, + alice_privkey, + bob_privkey, + watcher_privkey, + Some(60), + ); + + // Alice's a_coin (MYCOIN1) balance is reduced by the DEX fee. + // The taker fee is non-refundable even when the swap is refunded. + // DEX fee is 2% of acoin_volume (50 MYCOIN1) = 1 MYCOIN1 + let acoin_volume = BigDecimal::from_str("50").unwrap(); + let dex_fee = &acoin_volume * BigDecimal::from_str("0.02").unwrap(); + assert_eq!( + balances.alice_acoin_balance_after.round(0), + balances.alice_acoin_balance_before.clone() - dex_fee + ); + // Alice's b_coin (MYCOIN) balance should be unchanged - she got her taker payment refunded + assert_eq!(balances.alice_bcoin_balance_after, balances.alice_bcoin_balance_before); +} + +#[test] +fn test_watcher_waits_for_taker_utxo() { + let alice_privkey = &hex::encode(random_secp256k1_secret()); + let bob_privkey = &hex::encode(random_secp256k1_secret()); + let watcher_privkey = &hex::encode(random_secp256k1_secret()); + + start_swaps_and_get_balances( + "MYCOIN1", + "MYCOIN", + 25., + 25., + 2., + &[], + SwapFlow::TakerSpendsMakerPayment, + alice_privkey, + bob_privkey, + watcher_privkey, + None, + ); +} + +#[test] +fn test_watcher_validate_taker_fee_utxo() { + let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run + let lock_duration = get_payment_locktime(); + let (_ctx, taker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); + let (_ctx, maker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); + let taker_pubkey = taker_coin.my_public_key().unwrap(); + + let taker_amount = MmNumber::from((10, 1)); + let dex_fee = DexFee::new_from_taker_coin(&taker_coin, maker_coin.ticker(), &taker_amount); + + let taker_fee = block_on(taker_coin.send_taker_fee(dex_fee, Uuid::new_v4().as_bytes(), lock_duration)).unwrap(); + + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: taker_fee.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + + block_on_f01(taker_coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + + let validate_taker_fee_res = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { + taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), + sender_pubkey: taker_pubkey.to_vec(), + min_block_number: 0, + lock_duration, + })); + assert!(validate_taker_fee_res.is_ok()); + + let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { + taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), + sender_pubkey: maker_coin.my_public_key().unwrap().to_vec(), + min_block_number: 0, + lock_duration, + })) + .unwrap_err() + .into_inner(); + + log!("error: {:?}", error); + match error { + ValidatePaymentError::WrongPaymentTx(err) => { + assert!(err.contains(INVALID_SENDER_ERR_LOG)) + }, + _ => panic!("Expected `WrongPaymentTx` invalid public key, found {:?}", error), + } + + let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { + taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), + sender_pubkey: taker_pubkey.to_vec(), + min_block_number: u64::MAX, + lock_duration, + })) + .unwrap_err() + .into_inner(); + log!("error: {:?}", error); + match error { + ValidatePaymentError::WrongPaymentTx(err) => { + assert!(err.contains(EARLY_CONFIRMATION_ERR_LOG)) + }, + _ => panic!( + "Expected `WrongPaymentTx` confirmed before min_block, found {:?}", + error + ), + } + + let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { + taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), + sender_pubkey: taker_pubkey.to_vec(), + min_block_number: 0, + lock_duration: 0, + })) + .unwrap_err() + .into_inner(); + log!("error: {:?}", error); + match error { + ValidatePaymentError::WrongPaymentTx(err) => { + assert!(err.contains(OLD_TRANSACTION_ERR_LOG)) + }, + _ => panic!("Expected `WrongPaymentTx` transaction too old, found {:?}", error), + } + + let mock_pubkey = taker_pubkey.to_vec(); + ::dex_pubkey + .mock_safe(move |_| MockResult::Return(Box::leak(Box::new(mock_pubkey.clone())))); + + let error = block_on_f01(taker_coin.watcher_validate_taker_fee(WatcherValidateTakerFeeInput { + taker_fee_hash: taker_fee.tx_hash_as_bytes().into_vec(), + sender_pubkey: taker_pubkey.to_vec(), + min_block_number: 0, + lock_duration, + })) + .unwrap_err() + .into_inner(); + log!("error: {:?}", error); + match error { + ValidatePaymentError::WrongPaymentTx(err) => { + assert!(err.contains(INVALID_RECEIVER_ERR_LOG)) + }, + _ => panic!( + "Expected `WrongPaymentTx` tx output script_pubkey doesn't match expected, found {:?}", + error + ), + } +} + +#[test] +fn test_watcher_validate_taker_payment_utxo() { + let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run + let time_lock_duration = get_payment_locktime(); + let wait_for_confirmation_until = wait_until_sec(time_lock_duration); + let time_lock = wait_for_confirmation_until; + + let (_ctx, taker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); + let taker_pubkey = taker_coin.my_public_key().unwrap(); + + let (_ctx, maker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); + let maker_pubkey = maker_coin.my_public_key().unwrap(); + + let secret_hash = dhash160(&generate_secret().unwrap()); + + let taker_payment = block_on(taker_coin.send_taker_payment(SendPaymentArgs { + time_lock_duration, + time_lock, + other_pubkey: &maker_pubkey, + secret_hash: secret_hash.as_slice(), + amount: BigDecimal::from(10), + swap_contract_address: &None, + swap_unique_data: &[], + payment_instructions: &None, + watcher_reward: None, + wait_for_confirmation_until, + })) + .unwrap(); + + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: taker_payment.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + block_on_f01(taker_coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + + let taker_payment_refund_preimage = block_on_f01(taker_coin.create_taker_payment_refund_preimage( + &taker_payment.tx_hex(), + time_lock, + &maker_pubkey, + secret_hash.as_slice(), + &None, + &[], + )) + .unwrap(); + let validate_taker_payment_res = + block_on_f01(taker_coin.watcher_validate_taker_payment(WatcherValidatePaymentInput { + payment_tx: taker_payment.tx_hex(), + taker_payment_refund_preimage: taker_payment_refund_preimage.tx_hex(), + time_lock, + taker_pub: taker_pubkey.to_vec(), + maker_pub: maker_pubkey.to_vec(), + secret_hash: secret_hash.to_vec(), + wait_until: timeout, + confirmations: 1, + maker_coin: MmCoinEnum::UtxoCoinVariant(maker_coin.clone()), + })); + assert!(validate_taker_payment_res.is_ok()); + + let error = block_on_f01(taker_coin.watcher_validate_taker_payment(WatcherValidatePaymentInput { + payment_tx: taker_payment.tx_hex(), + taker_payment_refund_preimage: taker_payment_refund_preimage.tx_hex(), + time_lock, + taker_pub: maker_pubkey.to_vec(), + maker_pub: maker_pubkey.to_vec(), + secret_hash: secret_hash.to_vec(), + wait_until: timeout, + confirmations: 1, + maker_coin: MmCoinEnum::UtxoCoinVariant(maker_coin.clone()), + })) + .unwrap_err() + .into_inner(); + + log!("error: {:?}", error); + match error { + ValidatePaymentError::WrongPaymentTx(err) => { + assert!(err.contains(INVALID_SENDER_ERR_LOG)) + }, + _ => panic!("Expected `WrongPaymentTx` {INVALID_SENDER_ERR_LOG}, found {:?}", error), + } + + // Used to get wrong swap id + let wrong_secret_hash = dhash160(&generate_secret().unwrap()); + let error = block_on_f01(taker_coin.watcher_validate_taker_payment(WatcherValidatePaymentInput { + payment_tx: taker_payment.tx_hex(), + taker_payment_refund_preimage: taker_payment_refund_preimage.tx_hex(), + time_lock, + taker_pub: taker_pubkey.to_vec(), + maker_pub: maker_pubkey.to_vec(), + secret_hash: wrong_secret_hash.to_vec(), + wait_until: timeout, + confirmations: 1, + maker_coin: MmCoinEnum::UtxoCoinVariant(maker_coin.clone()), + })) + .unwrap_err() + .into_inner(); + + log!("error: {:?}", error); + match error { + ValidatePaymentError::WrongPaymentTx(err) => { + assert!(err.contains(INVALID_SCRIPT_ERR_LOG)) + }, + _ => panic!( + "Expected `WrongPaymentTx` {}, found {:?}", + INVALID_SCRIPT_ERR_LOG, error + ), + } + + let taker_payment_wrong_secret = block_on(taker_coin.send_taker_payment(SendPaymentArgs { + time_lock_duration, + time_lock, + other_pubkey: &maker_pubkey, + secret_hash: wrong_secret_hash.as_slice(), + amount: BigDecimal::from(10), + swap_contract_address: &taker_coin.swap_contract_address(), + swap_unique_data: &[], + payment_instructions: &None, + watcher_reward: None, + wait_for_confirmation_until, + })) + .unwrap(); + + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: taker_payment_wrong_secret.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + block_on_f01(taker_coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + + let error = block_on_f01(taker_coin.watcher_validate_taker_payment(WatcherValidatePaymentInput { + payment_tx: taker_payment.tx_hex(), + taker_payment_refund_preimage: taker_payment_refund_preimage.tx_hex(), + time_lock: 500, + taker_pub: taker_pubkey.to_vec(), + maker_pub: maker_pubkey.to_vec(), + secret_hash: wrong_secret_hash.to_vec(), + wait_until: timeout, + confirmations: 1, + maker_coin: MmCoinEnum::UtxoCoinVariant(maker_coin.clone()), + })) + .unwrap_err() + .into_inner(); + + log!("error: {:?}", error); + match error { + ValidatePaymentError::WrongPaymentTx(err) => { + assert!(err.contains(INVALID_SCRIPT_ERR_LOG)) + }, + _ => panic!( + "Expected `WrongPaymentTx` {}, found {:?}", + INVALID_SCRIPT_ERR_LOG, error + ), + } + + let wrong_taker_payment_refund_preimage = block_on_f01(taker_coin.create_taker_payment_refund_preimage( + &taker_payment.tx_hex(), + time_lock, + &maker_pubkey, + wrong_secret_hash.as_slice(), + &None, + &[], + )) + .unwrap(); + + let error = block_on_f01(taker_coin.watcher_validate_taker_payment(WatcherValidatePaymentInput { + payment_tx: taker_payment.tx_hex(), + taker_payment_refund_preimage: wrong_taker_payment_refund_preimage.tx_hex(), + time_lock, + taker_pub: taker_pubkey.to_vec(), + maker_pub: maker_pubkey.to_vec(), + secret_hash: secret_hash.to_vec(), + wait_until: timeout, + confirmations: 1, + maker_coin: MmCoinEnum::UtxoCoinVariant(maker_coin), + })) + .unwrap_err() + .into_inner(); + + log!("error: {:?}", error); + match error { + ValidatePaymentError::WrongPaymentTx(err) => { + assert!(err.contains(INVALID_REFUND_TX_ERR_LOG)) + }, + _ => panic!( + "Expected `WrongPaymentTx` {}, found {:?}", + INVALID_REFUND_TX_ERR_LOG, error + ), + } +} + +#[test] +fn test_taker_validates_taker_payment_refund_utxo() { + let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run + let time_lock_duration = get_payment_locktime(); + let wait_for_confirmation_until = wait_until_sec(time_lock_duration); + let time_lock = now_sec() - 10; + + let (_ctx, taker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); + let (_ctx, maker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); + let maker_pubkey = maker_coin.my_public_key().unwrap(); + + let secret_hash = dhash160(&generate_secret().unwrap()); + + let taker_payment = block_on(taker_coin.send_taker_payment(SendPaymentArgs { + time_lock_duration, + time_lock, + other_pubkey: &maker_pubkey, + secret_hash: secret_hash.as_slice(), + amount: BigDecimal::from(10), + swap_contract_address: &None, + swap_unique_data: &[], + payment_instructions: &None, + watcher_reward: None, + wait_for_confirmation_until, + })) + .unwrap(); + + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: taker_payment.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + block_on_f01(taker_coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + + let taker_payment_refund_preimage = block_on_f01(taker_coin.create_taker_payment_refund_preimage( + &taker_payment.tx_hex(), + time_lock, + &maker_pubkey, + secret_hash.as_slice(), + &None, + &[], + )) + .unwrap(); + + let taker_payment_refund = block_on_f01(taker_coin.send_taker_payment_refund_preimage(RefundPaymentArgs { + payment_tx: &taker_payment_refund_preimage.tx_hex(), + other_pubkey: &maker_pubkey, + tx_type_with_secret_hash: SwapTxTypeWithSecretHash::TakerOrMakerPayment { + maker_secret_hash: secret_hash.as_slice(), + }, + time_lock, + swap_contract_address: &None, + swap_unique_data: &[], + watcher_reward: false, + })) + .unwrap(); + + let validate_input = ValidateWatcherSpendInput { + payment_tx: taker_payment_refund.tx_hex(), + maker_pub: maker_pubkey.to_vec(), + swap_contract_address: None, + time_lock, + secret_hash: secret_hash.to_vec(), + amount: BigDecimal::from(10), + watcher_reward: None, + spend_type: WatcherSpendType::TakerPaymentRefund, + }; + + let validate_watcher_refund = block_on_f01(taker_coin.taker_validates_payment_spend_or_refund(validate_input)); + assert!(validate_watcher_refund.is_ok()); +} + +#[test] +fn test_taker_validates_maker_payment_spend_utxo() { + let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run + let time_lock_duration = get_payment_locktime(); + let wait_for_confirmation_until = wait_until_sec(time_lock_duration); + let time_lock = wait_for_confirmation_until; + + let (_ctx, taker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); + let (_ctx, maker_coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); + let taker_pubkey = taker_coin.my_public_key().unwrap(); + let maker_pubkey = maker_coin.my_public_key().unwrap(); + + let secret = generate_secret().unwrap(); + let secret_hash = dhash160(&secret); + + let maker_payment = block_on(maker_coin.send_maker_payment(SendPaymentArgs { + time_lock_duration, + time_lock, + other_pubkey: &taker_pubkey, + secret_hash: secret_hash.as_slice(), + amount: BigDecimal::from(10), + swap_contract_address: &None, + swap_unique_data: &[], + payment_instructions: &None, + watcher_reward: None, + wait_for_confirmation_until, + })) + .unwrap(); + + block_on_f01(maker_coin.wait_for_confirmations(ConfirmPaymentInput { + payment_tx: maker_payment.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + })) + .unwrap(); + + let maker_payment_spend_preimage = block_on_f01(taker_coin.create_maker_payment_spend_preimage( + &maker_payment.tx_hex(), + time_lock, + &maker_pubkey, + secret_hash.as_slice(), + &[], + )) + .unwrap(); + + let maker_payment_spend = block_on_f01(taker_coin.send_maker_payment_spend_preimage( + SendMakerPaymentSpendPreimageInput { + preimage: &maker_payment_spend_preimage.tx_hex(), + secret_hash: secret_hash.as_slice(), + secret: secret.as_slice(), + taker_pub: &taker_pubkey, + watcher_reward: false, + }, + )) + .unwrap(); + + let validate_input = ValidateWatcherSpendInput { + payment_tx: maker_payment_spend.tx_hex(), + maker_pub: maker_pubkey.to_vec(), + swap_contract_address: None, + time_lock, + secret_hash: secret_hash.to_vec(), + amount: BigDecimal::from(10), + watcher_reward: None, + spend_type: WatcherSpendType::TakerPaymentRefund, + }; + + let validate_watcher_spend = block_on_f01(taker_coin.taker_validates_payment_spend_or_refund(validate_input)); + assert!(validate_watcher_spend.is_ok()); +} + +#[test] +fn test_send_taker_payment_refund_preimage_utxo() { + let timeout = wait_until_sec(120); // timeout if test takes more than 120 seconds to run + let (_ctx, coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); + let my_public_key = coin.my_public_key().unwrap(); + + let time_lock = now_sec() - 3600; + let taker_payment_args = SendPaymentArgs { + time_lock_duration: 0, + time_lock, + other_pubkey: &my_public_key, + secret_hash: &[0; 20], + amount: 1u64.into(), + swap_contract_address: &None, + swap_unique_data: &[], + payment_instructions: &None, + watcher_reward: None, + wait_for_confirmation_until: 0, + }; + let tx = block_on(coin.send_taker_payment(taker_payment_args)).unwrap(); + + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: tx.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + + let refund_tx = block_on_f01(coin.create_taker_payment_refund_preimage( + &tx.tx_hex(), + time_lock, + &my_public_key, + &[0; 20], + &None, + &[], + )) + .unwrap(); + + let refund_tx = block_on_f01(coin.send_taker_payment_refund_preimage(RefundPaymentArgs { + payment_tx: &refund_tx.tx_hex(), + swap_contract_address: &None, + tx_type_with_secret_hash: SwapTxTypeWithSecretHash::TakerOrMakerPayment { + maker_secret_hash: &[0; 20], + }, + other_pubkey: &my_public_key, + time_lock, + swap_unique_data: &[], + watcher_reward: false, + })) + .unwrap(); + + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: refund_tx.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + + let pubkey = coin.my_public_key().unwrap(); + let search_input = SearchForSwapTxSpendInput { + time_lock, + other_pub: &pubkey, + secret_hash: &[0; 20], + tx: &tx.tx_hex(), + search_from_block: 0, + swap_contract_address: &None, + swap_unique_data: &[], + }; + let found = block_on(coin.search_for_swap_tx_spend_my(search_input)) + .unwrap() + .unwrap(); + assert_eq!(FoundSwapTxSpend::Refunded(refund_tx), found); +} diff --git a/mm2src/mm2_main/tests/docker_tests/swaps_confs_settings_sync_tests.rs b/mm2src/mm2_main/tests/docker_tests/swaps_confs_settings_sync_tests.rs index bda9b8fdfd..1b5996c2a3 100644 --- a/mm2src/mm2_main/tests/docker_tests/swaps_confs_settings_sync_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/swaps_confs_settings_sync_tests.rs @@ -1,10 +1,10 @@ -use crate::generate_utxo_coin_with_random_privkey; +use crate::docker_tests::helpers::utxo::generate_utxo_coin_with_random_privkey; use crate::integration_tests_common::enable_native; use common::block_on; use mm2_main::lp_swap::get_payment_locktime; use mm2_rpc::data::legacy::OrderConfirmationsSettings; use mm2_test_helpers::for_tests::{mm_dump, MarketMakerIt}; -use serde_json::Value as Json; +use serde_json::{json, Value as Json}; use std::thread; use std::time::Duration; diff --git a/mm2src/mm2_main/tests/docker_tests/swaps_file_lock_tests.rs b/mm2src/mm2_main/tests/docker_tests/swaps_file_lock_tests.rs index fdabe53544..160d2d1434 100644 --- a/mm2src/mm2_main/tests/docker_tests/swaps_file_lock_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/swaps_file_lock_tests.rs @@ -1,4 +1,4 @@ -use crate::generate_utxo_coin_with_random_privkey; +use crate::docker_tests::helpers::utxo::generate_utxo_coin_with_random_privkey; use crate::integration_tests_common::enable_native; use bitcrypto::ChecksumType; use common::block_on; @@ -6,7 +6,7 @@ use crypto::Secp256k1Secret; use keys::{KeyPair, Private}; use mm2_io::file_lock::FileLock; use mm2_test_helpers::for_tests::{mm_dump, new_mm2_temp_folder_path, MarketMakerIt}; -use serde_json::Value as Json; +use serde_json::{json, Value as Json}; use std::thread; use std::time::Duration; diff --git a/mm2src/mm2_main/tests/docker_tests/tendermint_swap_tests.rs b/mm2src/mm2_main/tests/docker_tests/tendermint_swap_tests.rs new file mode 100644 index 0000000000..c9015e8875 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/tendermint_swap_tests.rs @@ -0,0 +1,475 @@ +// Tendermint Cross-Chain Swap Tests +// +// This module contains tests that require Tendermint AND other chain types (ETH, Electrum). +// These tests cannot be placed in tendermint_tests.rs because they require additional +// infrastructure beyond Tendermint nodes. +// +// Tests: +// - swap_nucleus_with_doc: NUCLEUS <-> DOC (Tendermint + Electrum) +// - swap_nucleus_with_eth: NUCLEUS <-> ETH (Tendermint + Geth) +// - swap_doc_with_iris_ibc_nucleus: DOC <-> IRIS-IBC-NUCLEUS (Tendermint + Electrum) +// +// Gated by: docker-tests-tendermint + docker-tests-eth (cross-chain Tendermint swaps) + +use crate::docker_tests::helpers::eth::{fill_eth, swap_contract_checksum, GETH_RPC_URL}; +use crate::integration_tests_common::enable_electrum; +use common::executor::Timer; +use common::{block_on, log}; +use compatible_time::Duration; +use ethereum_types::{Address, U256}; +use mm2_number::BigDecimal; +use mm2_rpc::data::legacy::OrderbookResponse; +use mm2_test_helpers::for_tests::{ + check_my_swap_status, check_recent_swaps, doc_conf, enable_eth_coin, enable_tendermint, eth_dev_conf, + iris_ibc_nucleus_testnet_conf, nucleus_testnet_conf, wait_check_stats_swap_status, MarketMakerIt, + DOC_ELECTRUM_ADDRS, +}; +use serde_json::json; +use std::convert::TryFrom; +use std::env; +use std::str::FromStr; +use std::sync::Mutex; +use std::thread; + +const NUCLEUS_TESTNET_RPC_URLS: &[&str] = &["http://localhost:26657"]; + +const BOB_PASSPHRASE: &str = "iris test seed"; +const ALICE_PASSPHRASE: &str = "iris test2 seed"; + +lazy_static! { + /// Makes sure that tests sending transactions run sequentially to prevent account sequence + /// mismatches as some addresses are used in multiple tests. + static ref SEQUENCE_LOCK: Mutex<()> = Mutex::new(()); +} + +#[test] +fn swap_nucleus_with_doc() { + let _lock = SEQUENCE_LOCK.lock().unwrap(); + let bob_passphrase = String::from(BOB_PASSPHRASE); + let alice_passphrase = String::from(ALICE_PASSPHRASE); + + let coins = json!([nucleus_testnet_conf(), doc_conf()]); + + let mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 8999, + "dht": "on", + "myipaddr": env::var("BOB_TRADE_IP") .ok(), + "rpcip": env::var("BOB_TRADE_IP") .ok(), + "canbind": env::var("BOB_TRADE_PORT") .ok().map (|s| s.parse::().unwrap()), + "passphrase": bob_passphrase, + "coins": coins, + "rpc_password": "password", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "password".into(), + None, + ) + .unwrap(); + + thread::sleep(Duration::from_secs(1)); + + let mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 8999, + "dht": "on", + "myipaddr": env::var("ALICE_TRADE_IP") .ok(), + "rpcip": env::var("ALICE_TRADE_IP") .ok(), + "passphrase": alice_passphrase, + "coins": coins, + "seednodes": [mm_bob.my_seed_addr()], + "rpc_password": "password", + "skip_startup_checks": true, + }), + "password".into(), + None, + ) + .unwrap(); + + thread::sleep(Duration::from_secs(1)); + + dbg!(block_on(enable_tendermint( + &mm_bob, + "NUCLEUS-TEST", + &[], + NUCLEUS_TESTNET_RPC_URLS, + false + ))); + + dbg!(block_on(enable_tendermint( + &mm_alice, + "NUCLEUS-TEST", + &[], + NUCLEUS_TESTNET_RPC_URLS, + false + ))); + + dbg!(block_on(enable_electrum(&mm_bob, "DOC", false, DOC_ELECTRUM_ADDRS,))); + + dbg!(block_on(enable_electrum(&mm_alice, "DOC", false, DOC_ELECTRUM_ADDRS,))); + + block_on(trade_base_rel_tendermint( + mm_bob, + mm_alice, + "NUCLEUS-TEST", + "DOC", + 1, + 2, + 0.008, + )); +} + +#[test] +fn swap_nucleus_with_eth() { + let _lock = SEQUENCE_LOCK.lock().unwrap(); + let bob_passphrase = String::from(BOB_PASSPHRASE); + let alice_passphrase = String::from(ALICE_PASSPHRASE); + const BOB_ETH_ADDRESS: &str = "0x7b338250f990954E3Ab034ccD32a917c2F607C2d"; + const ALICE_ETH_ADDRESS: &str = "0x37602b7a648b207ACFD19E67253f57669bEA4Ad8"; + + fill_eth( + Address::from_str(BOB_ETH_ADDRESS).unwrap(), + U256::from(10).pow(U256::from(20)), + ); + + fill_eth( + Address::from_str(ALICE_ETH_ADDRESS).unwrap(), + U256::from(10).pow(U256::from(20)), + ); + + let coins = json!([nucleus_testnet_conf(), eth_dev_conf()]); + + let mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 8999, + "dht": "on", + "myipaddr": env::var("BOB_TRADE_IP") .ok(), + "rpcip": env::var("BOB_TRADE_IP") .ok(), + "canbind": env::var("BOB_TRADE_PORT") .ok().map (|s| s.parse::().unwrap()), + "passphrase": bob_passphrase, + "coins": coins, + "rpc_password": "password", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "password".into(), + None, + ) + .unwrap(); + + thread::sleep(Duration::from_secs(1)); + + let mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 8999, + "dht": "on", + "myipaddr": env::var("ALICE_TRADE_IP") .ok(), + "rpcip": env::var("ALICE_TRADE_IP") .ok(), + "passphrase": alice_passphrase, + "coins": coins, + "seednodes": [mm_bob.my_seed_addr()], + "rpc_password": "password", + "skip_startup_checks": true, + }), + "password".into(), + None, + ) + .unwrap(); + + thread::sleep(Duration::from_secs(1)); + + dbg!(block_on(enable_tendermint( + &mm_bob, + "NUCLEUS-TEST", + &[], + NUCLEUS_TESTNET_RPC_URLS, + false + ))); + + dbg!(block_on(enable_tendermint( + &mm_alice, + "NUCLEUS-TEST", + &[], + NUCLEUS_TESTNET_RPC_URLS, + false + ))); + + let swap_contract = swap_contract_checksum(); + + dbg!(block_on(enable_eth_coin( + &mm_bob, + "ETH", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + + dbg!(block_on(enable_eth_coin( + &mm_alice, + "ETH", + &[GETH_RPC_URL], + &swap_contract, + None, + false + ))); + + block_on(trade_base_rel_tendermint( + mm_bob, + mm_alice, + "NUCLEUS-TEST", + "ETH", + 1, + 2, + 0.008, + )); +} + +#[test] +fn swap_doc_with_iris_ibc_nucleus() { + let _lock = SEQUENCE_LOCK.lock().unwrap(); + let bob_passphrase = String::from(BOB_PASSPHRASE); + let alice_passphrase = String::from(ALICE_PASSPHRASE); + + let coins = json!([nucleus_testnet_conf(), iris_ibc_nucleus_testnet_conf(), doc_conf()]); + + let mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 8999, + "dht": "on", + "myipaddr": env::var("BOB_TRADE_IP") .ok(), + "rpcip": env::var("BOB_TRADE_IP") .ok(), + "canbind": env::var("BOB_TRADE_PORT") .ok().map (|s| s.parse::().unwrap()), + "passphrase": bob_passphrase, + "coins": coins, + "rpc_password": "password", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "password".into(), + None, + ) + .unwrap(); + + thread::sleep(Duration::from_secs(1)); + + let mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 8999, + "dht": "on", + "myipaddr": env::var("ALICE_TRADE_IP") .ok(), + "rpcip": env::var("ALICE_TRADE_IP") .ok(), + "passphrase": alice_passphrase, + "coins": coins, + "seednodes": [mm_bob.my_seed_addr()], + "rpc_password": "password", + "skip_startup_checks": true, + }), + "password".into(), + None, + ) + .unwrap(); + + thread::sleep(Duration::from_secs(1)); + + dbg!(block_on(enable_tendermint( + &mm_bob, + "NUCLEUS-TEST", + &["IRIS-IBC-NUCLEUS-TEST"], + NUCLEUS_TESTNET_RPC_URLS, + false + ))); + + dbg!(block_on(enable_tendermint( + &mm_alice, + "NUCLEUS-TEST", + &["IRIS-IBC-NUCLEUS-TEST"], + NUCLEUS_TESTNET_RPC_URLS, + false + ))); + + dbg!(block_on(enable_electrum(&mm_bob, "DOC", false, DOC_ELECTRUM_ADDRS))); + + dbg!(block_on(enable_electrum(&mm_alice, "DOC", false, DOC_ELECTRUM_ADDRS))); + + block_on(trade_base_rel_tendermint( + mm_bob, + mm_alice, + "DOC", + "IRIS-IBC-NUCLEUS-TEST", + 1, + 2, + 0.008, + )); +} + +pub async fn trade_base_rel_tendermint( + mut mm_bob: MarketMakerIt, + mut mm_alice: MarketMakerIt, + base: &str, + rel: &str, + maker_price: i32, + taker_price: i32, + volume: f64, +) { + log!("Issue bob {}/{} sell request", base, rel); + let rc = mm_bob + .rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": base, + "rel": rel, + "price": maker_price, + "volume": volume + })) + .await + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + let mut uuids = vec![]; + + common::log::info!( + "Trigger alice subscription to {}/{} orderbook topic first and sleep for 1 second", + base, + rel + ); + let rc = mm_alice + .rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "orderbook", + "base": base, + "rel": rel, + })) + .await + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + Timer::sleep(1.).await; + common::log::info!("Issue alice {}/{} buy request", base, rel); + let rc = mm_alice + .rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "buy", + "base": base, + "rel": rel, + "volume": volume, + "price": taker_price + })) + .await + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + let buy_json: serde_json::Value = serde_json::from_str(&rc.1).unwrap(); + uuids.push(buy_json["result"]["uuid"].as_str().unwrap().to_owned()); + + // ensure the swaps are started + let expected_log = format!("Entering the taker_swap_loop {base}/{rel}"); + mm_alice + .wait_for_log(5., |log| log.contains(&expected_log)) + .await + .unwrap(); + let expected_log = format!("Entering the maker_swap_loop {base}/{rel}"); + mm_bob + .wait_for_log(5., |log| log.contains(&expected_log)) + .await + .unwrap(); + + for uuid in uuids.iter() { + // ensure the swaps are indexed to the SQLite database + let expected_log = format!("Inserting new swap {uuid} to the SQLite database"); + mm_alice + .wait_for_log(5., |log| log.contains(&expected_log)) + .await + .unwrap(); + mm_bob + .wait_for_log(5., |log| log.contains(&expected_log)) + .await + .unwrap() + } + + for uuid in uuids.iter() { + match mm_bob + .wait_for_log(180., |log| log.contains(&format!("[swap uuid={uuid}] Finished"))) + .await + { + Ok(_) => (), + Err(_) => { + log!("{}", mm_bob.log_as_utf8().unwrap()); + }, + } + + match mm_alice + .wait_for_log(180., |log| log.contains(&format!("[swap uuid={uuid}] Finished"))) + .await + { + Ok(_) => (), + Err(_) => { + log!("{}", mm_alice.log_as_utf8().unwrap()); + }, + } + + log!("Waiting a few second for the fresh swap status to be saved.."); + Timer::sleep(5.).await; + + log!("{}", mm_alice.log_as_utf8().unwrap()); + log!("Checking alice/taker status.."); + check_my_swap_status( + &mm_alice, + uuid, + BigDecimal::try_from(volume).unwrap(), + BigDecimal::try_from(volume).unwrap(), + ) + .await; + + log!("{}", mm_bob.log_as_utf8().unwrap()); + log!("Checking bob/maker status.."); + check_my_swap_status( + &mm_bob, + uuid, + BigDecimal::try_from(volume).unwrap(), + BigDecimal::try_from(volume).unwrap(), + ) + .await; + } + + log!("Waiting 3 seconds for nodes to broadcast their swaps data.."); + Timer::sleep(3.).await; + + for uuid in uuids.iter() { + log!("Checking alice status.."); + wait_check_stats_swap_status(&mm_alice, uuid, 240).await; + + log!("Checking bob status.."); + wait_check_stats_swap_status(&mm_bob, uuid, 240).await; + } + + log!("Checking alice recent swaps.."); + check_recent_swaps(&mm_alice, uuids.len()).await; + log!("Checking bob recent swaps.."); + check_recent_swaps(&mm_bob, uuids.len()).await; + log!("Get {}/{} orderbook", base, rel); + let rc = mm_bob + .rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "orderbook", + "base": base, + "rel": rel, + })) + .await + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let bob_orderbook: OrderbookResponse = serde_json::from_str(&rc.1).unwrap(); + log!("{}/{} orderbook {:?}", base, rel, bob_orderbook); + + assert_eq!(0, bob_orderbook.bids.len(), "{base} {rel} bids must be empty"); + assert_eq!(0, bob_orderbook.asks.len(), "{base} {rel} asks must be empty"); + + mm_bob.stop().await.unwrap(); + mm_alice.stop().await.unwrap(); +} diff --git a/mm2src/mm2_main/tests/docker_tests/tendermint_tests.rs b/mm2src/mm2_main/tests/docker_tests/tendermint_tests.rs index 480b93063b..2472ba9518 100644 --- a/mm2src/mm2_main/tests/docker_tests/tendermint_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/tendermint_tests.rs @@ -812,458 +812,3 @@ fn test_tendermint_remove_delegation() { let undelegation_entry = undelegation_info["entries"].as_array().unwrap().last().unwrap(); assert_eq!(undelegation_entry["balance"], "0.15"); } - -mod swap { - use super::*; - - use crate::docker_tests::eth_docker_tests::fill_eth; - use crate::docker_tests::eth_docker_tests::swap_contract; - use crate::integration_tests_common::enable_electrum; - use common::executor::Timer; - use common::log; - use compatible_time::Duration; - use ethereum_types::{Address, U256}; - use mm2_rpc::data::legacy::OrderbookResponse; - use mm2_test_helpers::for_tests::{ - check_my_swap_status, check_recent_swaps, doc_conf, enable_eth_coin, iris_ibc_nucleus_testnet_conf, - nucleus_testnet_conf, wait_check_stats_swap_status, DOC_ELECTRUM_ADDRS, - }; - use std::convert::TryFrom; - use std::env; - use std::str::FromStr; - - const BOB_PASSPHRASE: &str = "iris test seed"; - const ALICE_PASSPHRASE: &str = "iris test2 seed"; - - #[test] - fn swap_nucleus_with_doc() { - let _lock = SEQUENCE_LOCK.lock().unwrap(); - let bob_passphrase = String::from(BOB_PASSPHRASE); - let alice_passphrase = String::from(ALICE_PASSPHRASE); - - let coins = json!([nucleus_testnet_conf(), doc_conf()]); - - let mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 8999, - "dht": "on", - "myipaddr": env::var("BOB_TRADE_IP") .ok(), - "rpcip": env::var("BOB_TRADE_IP") .ok(), - "canbind": env::var("BOB_TRADE_PORT") .ok().map (|s| s.parse::().unwrap()), - "passphrase": bob_passphrase, - "coins": coins, - "rpc_password": "password", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "password".into(), - None, - ) - .unwrap(); - - thread::sleep(Duration::from_secs(1)); - - let mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 8999, - "dht": "on", - "myipaddr": env::var("ALICE_TRADE_IP") .ok(), - "rpcip": env::var("ALICE_TRADE_IP") .ok(), - "passphrase": alice_passphrase, - "coins": coins, - "seednodes": [mm_bob.my_seed_addr()], - "rpc_password": "password", - "skip_startup_checks": true, - }), - "password".into(), - None, - ) - .unwrap(); - - thread::sleep(Duration::from_secs(1)); - - dbg!(block_on(enable_tendermint( - &mm_bob, - "NUCLEUS-TEST", - &[], - NUCLEUS_TESTNET_RPC_URLS, - false - ))); - - dbg!(block_on(enable_tendermint( - &mm_alice, - "NUCLEUS-TEST", - &[], - NUCLEUS_TESTNET_RPC_URLS, - false - ))); - - dbg!(block_on(enable_electrum(&mm_bob, "DOC", false, DOC_ELECTRUM_ADDRS,))); - - dbg!(block_on(enable_electrum(&mm_alice, "DOC", false, DOC_ELECTRUM_ADDRS,))); - - block_on(trade_base_rel_tendermint( - mm_bob, - mm_alice, - "NUCLEUS-TEST", - "DOC", - 1, - 2, - 0.008, - )); - } - - #[test] - fn swap_nucleus_with_eth() { - let _lock = SEQUENCE_LOCK.lock().unwrap(); - let bob_passphrase = String::from(BOB_PASSPHRASE); - let alice_passphrase = String::from(ALICE_PASSPHRASE); - const BOB_ETH_ADDRESS: &str = "0x7b338250f990954E3Ab034ccD32a917c2F607C2d"; - const ALICE_ETH_ADDRESS: &str = "0x37602b7a648b207ACFD19E67253f57669bEA4Ad8"; - - fill_eth( - Address::from_str(BOB_ETH_ADDRESS).unwrap(), - U256::from(10).pow(U256::from(20)), - ); - - fill_eth( - Address::from_str(ALICE_ETH_ADDRESS).unwrap(), - U256::from(10).pow(U256::from(20)), - ); - - let coins = json!([nucleus_testnet_conf(), crate::eth_dev_conf()]); - - let mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 8999, - "dht": "on", - "myipaddr": env::var("BOB_TRADE_IP") .ok(), - "rpcip": env::var("BOB_TRADE_IP") .ok(), - "canbind": env::var("BOB_TRADE_PORT") .ok().map (|s| s.parse::().unwrap()), - "passphrase": bob_passphrase, - "coins": coins, - "rpc_password": "password", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "password".into(), - None, - ) - .unwrap(); - - thread::sleep(Duration::from_secs(1)); - - let mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 8999, - "dht": "on", - "myipaddr": env::var("ALICE_TRADE_IP") .ok(), - "rpcip": env::var("ALICE_TRADE_IP") .ok(), - "passphrase": alice_passphrase, - "coins": coins, - "seednodes": [mm_bob.my_seed_addr()], - "rpc_password": "password", - "skip_startup_checks": true, - }), - "password".into(), - None, - ) - .unwrap(); - - thread::sleep(Duration::from_secs(1)); - - dbg!(block_on(enable_tendermint( - &mm_bob, - "NUCLEUS-TEST", - &[], - NUCLEUS_TESTNET_RPC_URLS, - false - ))); - - dbg!(block_on(enable_tendermint( - &mm_alice, - "NUCLEUS-TEST", - &[], - NUCLEUS_TESTNET_RPC_URLS, - false - ))); - - let swap_contract = format!("0x{}", hex::encode(swap_contract())); - - dbg!(block_on(enable_eth_coin( - &mm_bob, - "ETH", - &[crate::GETH_RPC_URL], - &swap_contract, - None, - false - ))); - - dbg!(block_on(enable_eth_coin( - &mm_alice, - "ETH", - &[crate::GETH_RPC_URL], - &swap_contract, - None, - false - ))); - - block_on(trade_base_rel_tendermint( - mm_bob, - mm_alice, - "NUCLEUS-TEST", - "ETH", - 1, - 2, - 0.008, - )); - } - - #[test] - fn swap_doc_with_iris_ibc_nucleus() { - let _lock = SEQUENCE_LOCK.lock().unwrap(); - let bob_passphrase = String::from(BOB_PASSPHRASE); - let alice_passphrase = String::from(ALICE_PASSPHRASE); - - let coins = json!([nucleus_testnet_conf(), iris_ibc_nucleus_testnet_conf(), doc_conf()]); - - let mm_bob = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 8999, - "dht": "on", - "myipaddr": env::var("BOB_TRADE_IP") .ok(), - "rpcip": env::var("BOB_TRADE_IP") .ok(), - "canbind": env::var("BOB_TRADE_PORT") .ok().map (|s| s.parse::().unwrap()), - "passphrase": bob_passphrase, - "coins": coins, - "rpc_password": "password", - "i_am_seed": true, - "is_bootstrap_node": true - }), - "password".into(), - None, - ) - .unwrap(); - - thread::sleep(Duration::from_secs(1)); - - let mm_alice = MarketMakerIt::start( - json!({ - "gui": "nogui", - "netid": 8999, - "dht": "on", - "myipaddr": env::var("ALICE_TRADE_IP") .ok(), - "rpcip": env::var("ALICE_TRADE_IP") .ok(), - "passphrase": alice_passphrase, - "coins": coins, - "seednodes": [mm_bob.my_seed_addr()], - "rpc_password": "password", - "skip_startup_checks": true, - }), - "password".into(), - None, - ) - .unwrap(); - - thread::sleep(Duration::from_secs(1)); - - dbg!(block_on(enable_tendermint( - &mm_bob, - "NUCLEUS-TEST", - &["IRIS-IBC-NUCLEUS-TEST"], - NUCLEUS_TESTNET_RPC_URLS, - false - ))); - - dbg!(block_on(enable_tendermint( - &mm_alice, - "NUCLEUS-TEST", - &["IRIS-IBC-NUCLEUS-TEST"], - NUCLEUS_TESTNET_RPC_URLS, - false - ))); - - dbg!(block_on(enable_electrum(&mm_bob, "DOC", false, DOC_ELECTRUM_ADDRS))); - - dbg!(block_on(enable_electrum(&mm_alice, "DOC", false, DOC_ELECTRUM_ADDRS))); - - block_on(trade_base_rel_tendermint( - mm_bob, - mm_alice, - "DOC", - "IRIS-IBC-NUCLEUS-TEST", - 1, - 2, - 0.008, - )); - } - - pub async fn trade_base_rel_tendermint( - mut mm_bob: MarketMakerIt, - mut mm_alice: MarketMakerIt, - base: &str, - rel: &str, - maker_price: i32, - taker_price: i32, - volume: f64, - ) { - log!("Issue bob {}/{} sell request", base, rel); - let rc = mm_bob - .rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "setprice", - "base": base, - "rel": rel, - "price": maker_price, - "volume": volume - })) - .await - .unwrap(); - assert!(rc.0.is_success(), "!setprice: {}", rc.1); - - let mut uuids = vec![]; - - common::log::info!( - "Trigger alice subscription to {}/{} orderbook topic first and sleep for 1 second", - base, - rel - ); - let rc = mm_alice - .rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "orderbook", - "base": base, - "rel": rel, - })) - .await - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - Timer::sleep(1.).await; - common::log::info!("Issue alice {}/{} buy request", base, rel); - let rc = mm_alice - .rpc(&json!({ - "userpass": mm_alice.userpass, - "method": "buy", - "base": base, - "rel": rel, - "volume": volume, - "price": taker_price - })) - .await - .unwrap(); - assert!(rc.0.is_success(), "!buy: {}", rc.1); - let buy_json: serde_json::Value = serde_json::from_str(&rc.1).unwrap(); - uuids.push(buy_json["result"]["uuid"].as_str().unwrap().to_owned()); - - // ensure the swaps are started - let expected_log = format!("Entering the taker_swap_loop {base}/{rel}"); - mm_alice - .wait_for_log(5., |log| log.contains(&expected_log)) - .await - .unwrap(); - let expected_log = format!("Entering the maker_swap_loop {base}/{rel}"); - mm_bob - .wait_for_log(5., |log| log.contains(&expected_log)) - .await - .unwrap(); - - for uuid in uuids.iter() { - // ensure the swaps are indexed to the SQLite database - let expected_log = format!("Inserting new swap {uuid} to the SQLite database"); - mm_alice - .wait_for_log(5., |log| log.contains(&expected_log)) - .await - .unwrap(); - mm_bob - .wait_for_log(5., |log| log.contains(&expected_log)) - .await - .unwrap() - } - - for uuid in uuids.iter() { - match mm_bob - .wait_for_log(180., |log| log.contains(&format!("[swap uuid={uuid}] Finished"))) - .await - { - Ok(_) => (), - Err(_) => { - log!("{}", mm_bob.log_as_utf8().unwrap()); - }, - } - - match mm_alice - .wait_for_log(180., |log| log.contains(&format!("[swap uuid={uuid}] Finished"))) - .await - { - Ok(_) => (), - Err(_) => { - log!("{}", mm_alice.log_as_utf8().unwrap()); - }, - } - - log!("Waiting a few second for the fresh swap status to be saved.."); - Timer::sleep(5.).await; - - log!("{}", mm_alice.log_as_utf8().unwrap()); - log!("Checking alice/taker status.."); - check_my_swap_status( - &mm_alice, - uuid, - BigDecimal::try_from(volume).unwrap(), - BigDecimal::try_from(volume).unwrap(), - ) - .await; - - log!("{}", mm_bob.log_as_utf8().unwrap()); - log!("Checking bob/maker status.."); - check_my_swap_status( - &mm_bob, - uuid, - BigDecimal::try_from(volume).unwrap(), - BigDecimal::try_from(volume).unwrap(), - ) - .await; - } - - log!("Waiting 3 seconds for nodes to broadcast their swaps data.."); - Timer::sleep(3.).await; - - for uuid in uuids.iter() { - log!("Checking alice status.."); - wait_check_stats_swap_status(&mm_alice, uuid, 240).await; - - log!("Checking bob status.."); - wait_check_stats_swap_status(&mm_bob, uuid, 240).await; - } - - log!("Checking alice recent swaps.."); - check_recent_swaps(&mm_alice, uuids.len()).await; - log!("Checking bob recent swaps.."); - check_recent_swaps(&mm_bob, uuids.len()).await; - log!("Get {}/{} orderbook", base, rel); - let rc = mm_bob - .rpc(&json!({ - "userpass": mm_bob.userpass, - "method": "orderbook", - "base": base, - "rel": rel, - })) - .await - .unwrap(); - assert!(rc.0.is_success(), "!orderbook: {}", rc.1); - - let bob_orderbook: OrderbookResponse = serde_json::from_str(&rc.1).unwrap(); - log!("{}/{} orderbook {:?}", base, rel, bob_orderbook); - - assert_eq!(0, bob_orderbook.bids.len(), "{base} {rel} bids must be empty"); - assert_eq!(0, bob_orderbook.asks.len(), "{base} {rel} asks must be empty"); - - mm_bob.stop().await.unwrap(); - mm_alice.stop().await.unwrap(); - } -} diff --git a/mm2src/mm2_main/tests/docker_tests/utxo_ordermatch_v1_tests.rs b/mm2src/mm2_main/tests/docker_tests/utxo_ordermatch_v1_tests.rs new file mode 100644 index 0000000000..6173e3ba9f --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/utxo_ordermatch_v1_tests.rs @@ -0,0 +1,1916 @@ +// UTXO Ordermatching V1 Tests +// +// This module contains UTXO-only ordermatching tests that were extracted from docker_tests_inner.rs +// These tests focus on orderbook behavior, order lifecycle, balance-driven updates, and matching logic. +// They do NOT require ETH/ERC20 containers - only MYCOIN/MYCOIN1 UTXO containers. +// +// Gated by: docker-tests-ordermatch + +use crate::docker_tests::helpers::env::random_secp256k1_secret; +use crate::docker_tests::helpers::utxo::{ + fill_address, generate_utxo_coin_with_privkey, generate_utxo_coin_with_random_privkey, rmd160_from_priv, +}; +use crate::integration_tests_common::*; +use coins::{ConfirmPaymentInput, MarketCoinOps, MmCoin, WithdrawRequest}; +use common::{block_on, block_on_f01, executor::Timer, wait_until_sec}; +use mm2_libp2p::behaviours::atomicdex::MAX_TIME_GAP_FOR_CONNECTED_PEER; +use mm2_number::{BigDecimal, BigRational}; +use mm2_test_helpers::for_tests::{ + check_my_swap_status_amounts, mm_dump, mycoin1_conf, mycoin_conf, MarketMakerIt, Mm2TestConf, +}; +use mm2_test_helpers::structs::*; +use serde_json::{json, Value as Json}; +use std::collections::HashMap; +use std::convert::TryInto; +use std::env; +use std::thread; +use std::time::Duration; + +// ============================================================================= +// Order Lifecycle Tests +// Tests for order creation, cancellation, and balance-driven updates +// ============================================================================= + +#[test] +// https://github.com/KomodoPlatform/atomicDEX-API/issues/554 +fn order_should_be_cancelled_when_entire_balance_is_withdrawn() { + let (_ctx, _, priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + + let mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "myipaddr": env::var("BOB_TRADE_IP") .ok(), + "rpcip": env::var("BOB_TRADE_IP") .ok(), + "canbind": env::var("BOB_TRADE_PORT") .ok().map (|s| s.parse::().unwrap()), + "passphrase": format!("0x{}", hex::encode(priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": "999", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + let json: Json = serde_json::from_str(&rc.1).unwrap(); + let bob_uuid = json["result"]["uuid"].as_str().unwrap().to_owned(); + + log!("Get MYCOIN/MYCOIN1 orderbook"); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("orderbook {:?}", bob_orderbook); + let asks = bob_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 1, "MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); + + let withdraw = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "withdraw", + "coin": "MYCOIN", + "max": true, + "to": "R9imXLs1hEcU9KbFDQq2hJEEJ1P5UoekaF", + }))) + .unwrap(); + assert!(withdraw.0.is_success(), "!withdraw: {}", withdraw.1); + + let withdraw: Json = serde_json::from_str(&withdraw.1).unwrap(); + + let send_raw = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "send_raw_transaction", + "coin": "MYCOIN", + "tx_hex": withdraw["tx_hex"], + }))) + .unwrap(); + assert!(send_raw.0.is_success(), "!send_raw: {}", send_raw.1); + + thread::sleep(Duration::from_secs(32)); + + log!("Get MYCOIN/MYCOIN1 orderbook"); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("orderbook {}", serde_json::to_string(&bob_orderbook).unwrap()); + let asks = bob_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 0, "MYCOIN/MYCOIN1 orderbook must have exactly 0 asks"); + + log!("Get my orders"); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "my_orders", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!my_orders: {}", rc.1); + let orders: Json = serde_json::from_str(&rc.1).unwrap(); + log!("my_orders {}", serde_json::to_string(&orders).unwrap()); + assert!( + orders["result"]["maker_orders"].as_object().unwrap().is_empty(), + "maker_orders must be empty" + ); + + let rmd160 = rmd160_from_priv(priv_key); + let order_path = mm_bob.folder.join(format!( + "DB/{}/ORDERS/MY/MAKER/{}.json", + hex::encode(rmd160.take()), + bob_uuid, + )); + log!("Order path {}", order_path.display()); + assert!(!order_path.exists()); + block_on(mm_bob.stop()).unwrap(); +} + +#[test] +fn order_should_be_updated_when_balance_is_decreased_alice_subscribes_after_update() { + let (_ctx, _, priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + + let mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "myipaddr": env::var("BOB_TRADE_IP") .ok(), + "rpcip": env::var("BOB_TRADE_IP") .ok(), + "canbind": env::var("BOB_TRADE_PORT") .ok().map (|s| s.parse::().unwrap()), + "passphrase": format!("0x{}", hex::encode(priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": "alice passphrase", + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": "999", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + log!("Get MYCOIN/MYCOIN1 orderbook"); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("orderbook {:?}", bob_orderbook); + let asks = bob_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 1, "MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); + + let withdraw = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "withdraw", + "coin": "MYCOIN", + "amount": "499.99999481", + "to": "R9imXLs1hEcU9KbFDQq2hJEEJ1P5UoekaF", + }))) + .unwrap(); + assert!(withdraw.0.is_success(), "!withdraw: {}", withdraw.1); + + let withdraw: Json = serde_json::from_str(&withdraw.1).unwrap(); + + let send_raw = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "send_raw_transaction", + "coin": "MYCOIN", + "tx_hex": withdraw["tx_hex"], + }))) + .unwrap(); + assert!(send_raw.0.is_success(), "!send_raw: {}", send_raw.1); + + thread::sleep(Duration::from_secs(32)); + + log!("Get MYCOIN/MYCOIN1 orderbook on Bob side"); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("orderbook {}", serde_json::to_string(&bob_orderbook).unwrap()); + let asks = bob_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 1, "Bob MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); + + let order_volume = asks[0]["maxvolume"].as_str().unwrap(); + assert_eq!("500", order_volume); // 1000.0 - (499.99999481 + 0.00000274 txfee) = (500.0 + 0.00000274 txfee) + + log!("Get MYCOIN/MYCOIN1 orderbook on Alice side"); + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let alice_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("orderbook {}", serde_json::to_string(&alice_orderbook).unwrap()); + let asks = alice_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 1, "Alice MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); + + let order_volume = asks[0]["maxvolume"].as_str().unwrap(); + assert_eq!("500", order_volume); + + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} + +#[test] +fn order_should_be_updated_when_balance_is_decreased_alice_subscribes_before_update() { + let (_ctx, _, priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "myipaddr": env::var("BOB_TRADE_IP") .ok(), + "rpcip": env::var("BOB_TRADE_IP") .ok(), + "canbind": env::var("BOB_TRADE_PORT") .ok().map (|s| s.parse::().unwrap()), + "passphrase": format!("0x{}", hex::encode(priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": "alice passphrase", + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": "999", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + log!("Get MYCOIN/MYCOIN1 orderbook"); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("orderbook {:?}", bob_orderbook); + let asks = bob_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 1, "Bob MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); + + thread::sleep(Duration::from_secs(2)); + log!("Get MYCOIN/MYCOIN1 orderbook on Alice side"); + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let alice_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("orderbook {}", serde_json::to_string(&alice_orderbook).unwrap()); + let asks = alice_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 1, "Alice MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); + + let withdraw = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "withdraw", + "coin": "MYCOIN", + "amount": "499.99999481", + "to": "R9imXLs1hEcU9KbFDQq2hJEEJ1P5UoekaF", + }))) + .unwrap(); + assert!(withdraw.0.is_success(), "!withdraw: {}", withdraw.1); + + let withdraw: Json = serde_json::from_str(&withdraw.1).unwrap(); + + let send_raw = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "send_raw_transaction", + "coin": "MYCOIN", + "tx_hex": withdraw["tx_hex"], + }))) + .unwrap(); + assert!(send_raw.0.is_success(), "!send_raw: {}", send_raw.1); + + thread::sleep(Duration::from_secs(32)); + + log!("Get MYCOIN/MYCOIN1 orderbook on Bob side"); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("orderbook {}", serde_json::to_string(&bob_orderbook).unwrap()); + let asks = bob_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 1, "Bob MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); + + let order_volume = asks[0]["maxvolume"].as_str().unwrap(); + assert_eq!("500", order_volume); // 1000.0 - (499.99999481 + 0.00000245 txfee) = (500.0 + 0.00000274 txfee) + + log!("Get MYCOIN/MYCOIN1 orderbook on Alice side"); + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let alice_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("orderbook {}", serde_json::to_string(&alice_orderbook).unwrap()); + let asks = alice_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 1, "Alice MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); + + let order_volume = asks[0]["maxvolume"].as_str().unwrap(); + assert_eq!("500", order_volume); + + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} + +// ============================================================================= +// Partial Fill Tests +// Tests for order updates when partially matched +// ============================================================================= + +#[test] +fn test_order_should_be_updated_when_matched_partially() { + let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 2000.into()); + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 2000.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let mut mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": "1000", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": "500", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + + block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + + log!("Get MYCOIN/MYCOIN1 orderbook on Bob side"); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("orderbook {}", serde_json::to_string(&bob_orderbook).unwrap()); + let asks = bob_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 1, "Bob MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); + + let order_volume = asks[0]["maxvolume"].as_str().unwrap(); + assert_eq!("500", order_volume); + + log!("Get MYCOIN/MYCOIN1 orderbook on Alice side"); + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let alice_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("orderbook {}", serde_json::to_string(&alice_orderbook).unwrap()); + let asks = alice_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 1, "Alice MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); + + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} + +// ============================================================================= +// Order Volume Tests +// Tests for setprice max volume and volume constraints +// ============================================================================= + +#[test] +fn test_set_price_max() { + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + // the result of equation x + 0.00001 = 1 + "volume": { + "numer":"99999", + "denom":"100000" + }, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + // it is slightly more than previous volume so it should fail + "volume": { + "numer":"100000", + "denom":"100000" + }, + }))) + .unwrap(); + assert!(!rc.0.is_success(), "setprice success, but should fail: {}", rc.1); + block_on(mm_alice.stop()).unwrap(); +} + +// ============================================================================= +// Order Restart/Persistence Tests +// Tests for maker order kickstart on MM restart +// ============================================================================= + +#[test] +fn test_maker_order_should_kick_start_and_appear_in_orderbook_on_restart() { + let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut bob_conf = json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }); + let mm_bob = MarketMakerIt::start(bob_conf.clone(), "pass".to_string(), None).unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "max": true, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + // mm_bob using same DB dir that should kick start the order + bob_conf["dbdir"] = mm_bob.folder.join("DB").to_str().unwrap().into(); + bob_conf["log"] = mm_bob.folder.join("mm2_dup.log").to_str().unwrap().into(); + block_on(mm_bob.stop()).unwrap(); + + let mm_bob_dup = MarketMakerIt::start(bob_conf, "pass".to_string(), None).unwrap(); + let (_bob_dup_dump_log, _bob_dup_dump_dashboard) = mm_dump(&mm_bob_dup.log_path); + log!("{:?}", block_on(enable_native(&mm_bob_dup, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob_dup, "MYCOIN1", &[], None))); + + thread::sleep(Duration::from_secs(2)); + + log!("Get MYCOIN/MYCOIN1 orderbook on Bob side"); + let rc = block_on(mm_bob_dup.rpc(&json!({ + "userpass": mm_bob_dup.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("Bob orderbook {:?}", bob_orderbook); + let asks = bob_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 1, "Bob MYCOIN/MYCOIN1 orderbook must have exactly 1 asks"); +} + +#[test] +fn test_maker_order_should_not_kick_start_and_appear_in_orderbook_if_balance_is_withdrawn() { + let (_ctx, coin, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut bob_conf = json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }); + let mm_bob = MarketMakerIt::start(bob_conf.clone(), "pass".to_string(), None).unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "max": true, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + let res: SetPriceResponse = serde_json::from_str(&rc.1).unwrap(); + let uuid = res.result.uuid; + + // mm_bob using same DB dir that should kick start the order + bob_conf["dbdir"] = mm_bob.folder.join("DB").to_str().unwrap().into(); + bob_conf["log"] = mm_bob.folder.join("mm2_dup.log").to_str().unwrap().into(); + block_on(mm_bob.stop()).unwrap(); + + let withdraw = block_on_f01(coin.withdraw(WithdrawRequest::new_max( + "MYCOIN".to_string(), + "RRYmiZSDo3UdHHqj1rLKf8cbJroyv9NxXw".to_string(), + ))) + .unwrap(); + block_on_f01(coin.send_raw_tx(&hex::encode(&withdraw.tx.tx_hex().unwrap().0))).unwrap(); + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: withdraw.tx.tx_hex().unwrap().0.to_owned(), + confirmations: 1, + requires_nota: false, + wait_until: wait_until_sec(10), + check_every: 1, + }; + block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + + let mm_bob_dup = MarketMakerIt::start(bob_conf, "pass".to_string(), None).unwrap(); + let (_bob_dup_dump_log, _bob_dup_dump_dashboard) = mm_dump(&mm_bob_dup.log_path); + log!("{:?}", block_on(enable_native(&mm_bob_dup, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob_dup, "MYCOIN1", &[], None))); + + thread::sleep(Duration::from_secs(2)); + + log!("Get MYCOIN/MYCOIN1 orderbook on Bob side"); + let rc = block_on(mm_bob_dup.rpc(&json!({ + "userpass": mm_bob_dup.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("Bob orderbook {:?}", bob_orderbook); + let asks = bob_orderbook["asks"].as_array().unwrap(); + assert!(asks.is_empty(), "Bob MYCOIN/MYCOIN1 orderbook must not have asks"); + + let rc = block_on(mm_bob_dup.rpc(&json!({ + "userpass": mm_bob_dup.userpass, + "method": "my_orders", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!my_orders: {}", rc.1); + + let res: MyOrdersRpcResult = serde_json::from_str(&rc.1).unwrap(); + assert!(res.result.maker_orders.is_empty(), "Bob maker orders must be empty"); + + let order_path = mm_bob.folder.join(format!( + "DB/{}/ORDERS/MY/MAKER/{}.json", + hex::encode(rmd160_from_priv(bob_priv_key).take()), + uuid + )); + + log!("Order path {}", order_path.display()); + assert!(!order_path.exists()); +} + +#[test] +fn test_maker_order_kick_start_should_trigger_subscription_and_match() { + let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1000.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + + let relay_conf = json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": "relay", + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }); + let relay = MarketMakerIt::start(relay_conf, "pass".to_string(), None).unwrap(); + let (_relay_dump_log, _relay_dump_dashboard) = mm_dump(&relay.log_path); + + let mut bob_conf = json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", relay.ip)], + }); + let mm_bob = MarketMakerIt::start(bob_conf.clone(), "pass".to_string(), None).unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let mut mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", relay.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "max": true, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + // mm_bob using same DB dir that should kick start the order + bob_conf["dbdir"] = mm_bob.folder.join("DB").to_str().unwrap().into(); + bob_conf["log"] = mm_bob.folder.join("mm2_dup.log").to_str().unwrap().into(); + block_on(mm_bob.stop()).unwrap(); + + let mut mm_bob_dup = MarketMakerIt::start(bob_conf, "pass".to_string(), None).unwrap(); + let (_bob_dup_dump_log, _bob_dup_dump_dashboard) = mm_dump(&mm_bob_dup.log_path); + log!("{:?}", block_on(enable_native(&mm_bob_dup, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob_dup, "MYCOIN1", &[], None))); + + log!("Give restarted Bob 2 seconds to kickstart the order"); + thread::sleep(Duration::from_secs(2)); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": 1, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + + block_on(mm_bob_dup.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); +} + +// ============================================================================= +// Same Private Key Edge Cases +// Tests for edge cases when using the same private key across nodes +// ============================================================================= + +#[test] +fn test_orders_should_match_on_both_nodes_with_same_priv() { + let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 2000.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let mut mm_alice_1 = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_1_dump_log, _alice_1_dump_dashboard) = mm_dump(&mm_alice_1.log_path); + + let mut mm_alice_2 = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_2_dump_log, _alice_2_dump_dashboard) = mm_dump(&mm_alice_2.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice_1, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice_1, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice_2, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice_2, "MYCOIN1", &[], None))); + + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "max": true, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + let rc = block_on(mm_alice_1.rpc(&json!({ + "userpass": mm_alice_1.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": "1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + + block_on(mm_alice_1.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + + let rc = block_on(mm_alice_2.rpc(&json!({ + "userpass": mm_alice_2.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": "1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + + block_on(mm_alice_2.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice_1.stop()).unwrap(); + block_on(mm_alice_2.stop()).unwrap(); +} + +#[test] +fn test_maker_and_taker_order_created_with_same_priv_should_not_match() { + let (_ctx, coin, priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let (_ctx, coin1, _) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1000.into()); + fill_address(&coin1, &coin.my_address().unwrap(), 1000.into(), 30); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let mut mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_1_dump_log, _alice_1_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "max": true, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": "1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + + block_on(mm_bob.wait_for_log(5., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap_err(); + block_on(mm_alice.wait_for_log(5., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap_err(); + + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} + +// ============================================================================= +// Order Conversion and Cancellation Tests +// Tests for taker-to-maker order conversion and proper cleanup +// ============================================================================= + +#[test] +fn test_taker_order_converted_to_maker_should_cancel_properly_when_matched() { + let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 2000.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let mut mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "sell", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": 1, + "timeout": 2, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!sell: {}", rc.1); + + log!("Give Bob 4 seconds to convert order to maker"); + block_on(Timer::sleep(4.)); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": 1, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + + block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + + log!("Give Bob 2 seconds to cancel the order"); + thread::sleep(Duration::from_secs(2)); + log!("Get my_orders on Bob side"); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "my_orders", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!my_orders: {}", rc.1); + let my_orders_json: Json = serde_json::from_str(&rc.1).unwrap(); + let maker_orders: HashMap = + serde_json::from_value(my_orders_json["result"]["maker_orders"].clone()).unwrap(); + assert!(maker_orders.is_empty()); + + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("Bob orderbook {:?}", bob_orderbook); + let asks = bob_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 0, "Bob MYCOIN/MYCOIN1 orderbook must be empty"); + + log!("Get MYCOIN/MYCOIN1 orderbook on Alice side"); + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let alice_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("Alice orderbook {:?}", alice_orderbook); + let asks = alice_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 0, "Alice MYCOIN/MYCOIN1 orderbook must be empty"); + + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} + +// ============================================================================= +// Best Price Matching Tests +// Tests for ensuring taker matches with best available price +// ============================================================================= + +// https://github.com/KomodoPlatform/atomicDEX-API/issues/1053 +#[test] +fn test_taker_should_match_with_best_price_buy() { + let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 2000.into()); + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 4000.into()); + let (_ctx, _, eve_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 2000.into()); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let mut mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + let mut mm_eve = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(eve_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_eve_dump_log, _eve_dump_dashboard) = mm_dump(&mm_eve.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_eve, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_eve, "MYCOIN1", &[], None))); + + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 2, + "max": true, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + let rc = block_on(mm_eve.rpc(&json!({ + "userpass": mm_eve.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "max": true, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + // subscribe alice to the orderbook topic to not miss eve's message + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!alice orderbook: {}", rc.1); + log!("alice orderbook {}", rc.1); + + thread::sleep(Duration::from_secs(1)); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 3, + "volume": "1000", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + let alice_buy: BuyOrSellRpcResult = serde_json::from_str(&rc.1).unwrap(); + + block_on(mm_eve.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + + thread::sleep(Duration::from_secs(2)); + + block_on(check_my_swap_status_amounts( + &mm_alice, + alice_buy.result.uuid, + 1000.into(), + 1000.into(), + )); + block_on(check_my_swap_status_amounts( + &mm_eve, + alice_buy.result.uuid, + 1000.into(), + 1000.into(), + )); + + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); + block_on(mm_eve.stop()).unwrap(); +} + +// https://github.com/KomodoPlatform/atomicDEX-API/issues/1053 +#[test] +fn test_taker_should_match_with_best_price_sell() { + let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 2000.into()); + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 4000.into()); + let (_ctx, _, eve_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 2000.into()); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let mut mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + let mut mm_eve = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(eve_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_eve_dump_log, _eve_dump_dashboard) = mm_dump(&mm_eve.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_eve, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_eve, "MYCOIN1", &[], None))); + + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 2, + "max": true, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + let rc = block_on(mm_eve.rpc(&json!({ + "userpass": mm_eve.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "max": true, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + // subscribe alice to the orderbook topic to not miss eve's message + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!alice orderbook: {}", rc.1); + log!("alice orderbook {}", rc.1); + + thread::sleep(Duration::from_secs(1)); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "sell", + "base": "MYCOIN1", + "rel": "MYCOIN", + "price": "0.1", + "volume": "1000", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!sell: {}", rc.1); + let alice_sell: BuyOrSellRpcResult = serde_json::from_str(&rc.1).unwrap(); + + block_on(mm_eve.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + + thread::sleep(Duration::from_secs(2)); + + block_on(check_my_swap_status_amounts( + &mm_alice, + alice_sell.result.uuid, + 1000.into(), + 1000.into(), + )); + block_on(check_my_swap_status_amounts( + &mm_eve, + alice_sell.result.uuid, + 1000.into(), + 1000.into(), + )); + + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); + block_on(mm_eve.stop()).unwrap(); +} + +// ============================================================================= +// RPC Response Format Tests +// Tests for validating RPC response formats (UTXO-only variants) +// ============================================================================= + +#[test] +fn test_set_price_response_format() { + let privkey = random_secp256k1_secret(); + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); + + let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("MM log path: {}", mm.log_path.display()); + + // Enable coins + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + + log!("Issue bob MYCOIN/MYCOIN1 sell request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": 0.1 + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + let rc_json: Json = serde_json::from_str(&rc.1).unwrap(); + let _: BigDecimal = serde_json::from_value(rc_json["result"]["max_base_vol"].clone()).unwrap(); + let _: BigDecimal = serde_json::from_value(rc_json["result"]["min_base_vol"].clone()).unwrap(); + let _: BigDecimal = serde_json::from_value(rc_json["result"]["price"].clone()).unwrap(); + + let _: BigRational = serde_json::from_value(rc_json["result"]["max_base_vol_rat"].clone()).unwrap(); + let _: BigRational = serde_json::from_value(rc_json["result"]["min_base_vol_rat"].clone()).unwrap(); + let _: BigRational = serde_json::from_value(rc_json["result"]["price_rat"].clone()).unwrap(); +} + +#[test] +fn test_buy_response_format() { + let privkey = random_secp256k1_secret(); + generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), privkey); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); + + let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("MM log path: {}", mm.log_path.display()); + + // Enable coins + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + + log!("Issue bob buy request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": 0.1, + "base_confs": 5, + "base_nota": true, + "rel_confs": 4, + "rel_nota": false, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + let _: BuyOrSellRpcResult = serde_json::from_str(&rc.1).unwrap(); +} + +#[test] +fn test_sell_response_format() { + let privkey = random_secp256k1_secret(); + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); + + let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("MM log path: {}", mm.log_path.display()); + + // Enable coins + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + + log!("Issue bob sell request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "sell", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": 0.1, + "base_confs": 5, + "base_nota": true, + "rel_confs": 4, + "rel_nota": false, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!sell: {}", rc.1); + let _: BuyOrSellRpcResult = serde_json::from_str(&rc.1).unwrap(); +} + +#[test] +fn test_my_orders_response_format() { + let privkey = random_secp256k1_secret(); + generate_utxo_coin_with_privkey("MYCOIN1", 10000.into(), privkey); + generate_utxo_coin_with_privkey("MYCOIN", 10000.into(), privkey); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); + + let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("MM log path: {}", mm.log_path.display()); + + // Enable coins + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + + log!("Issue bob buy request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": 0.1, + "base_confs": 5, + "base_nota": true, + "rel_confs": 4, + "rel_nota": false, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + + log!("Issue bob setprice request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": 0.1, + "base_confs": 5, + "base_nota": true, + "rel_confs": 4, + "rel_nota": false, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + log!("Issue bob my_orders request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "my_orders", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!my_orders: {}", rc.1); + + let _: MyOrdersRpcResult = serde_json::from_str(&rc.1).unwrap(); +} + +// ============================================================================= +// Min Volume and Dust Tests +// Tests for order min_volume constraints and dust thresholds +// ============================================================================= + +#[test] +fn test_buy_min_volume() { + let privkey = random_secp256k1_secret(); + generate_utxo_coin_with_privkey("MYCOIN1", 1000.into(), privkey); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); + + let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("MM log path: {}", mm.log_path.display()); + + // Enable coins + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + + let min_volume: BigDecimal = "0.1".parse().unwrap(); + log!("Issue bob MYCOIN/MYCOIN1 buy request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": "2", + "volume": "1", + "min_volume": min_volume, + "order_type": { + "type": "GoodTillCancelled" + }, + "timeout": 2, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!sell: {}", rc.1); + let response: BuyOrSellRpcResult = serde_json::from_str(&rc.1).unwrap(); + assert_eq!(min_volume, response.result.min_volume); + + log!("Wait for 4 seconds for Bob order to be converted to maker"); + block_on(Timer::sleep(4.)); + + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "my_orders", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!my_orders: {}", rc.1); + let my_orders: MyOrdersRpcResult = serde_json::from_str(&rc.1).unwrap(); + assert_eq!( + 1, + my_orders.result.maker_orders.len(), + "maker_orders must have exactly 1 order" + ); + assert!(my_orders.result.taker_orders.is_empty(), "taker_orders must be empty"); + let maker_order = my_orders.result.maker_orders.get(&response.result.uuid).unwrap(); + + let expected_min_volume: BigDecimal = "0.2".parse().unwrap(); + assert_eq!(expected_min_volume, maker_order.min_base_vol); +} + +#[test] +fn test_sell_min_volume() { + let privkey = random_secp256k1_secret(); + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); + + let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("MM log path: {}", mm.log_path.display()); + + // Enable coins + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + + let min_volume: BigDecimal = "0.1".parse().unwrap(); + log!("Issue bob MYCOIN/MYCOIN1 sell request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "sell", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": "1", + "volume": "1", + "min_volume": min_volume, + "order_type": { + "type": "GoodTillCancelled" + }, + "timeout": 2, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!sell: {}", rc.1); + let rc_json: Json = serde_json::from_str(&rc.1).unwrap(); + let uuid: String = serde_json::from_value(rc_json["result"]["uuid"].clone()).unwrap(); + let min_volume_response: BigDecimal = serde_json::from_value(rc_json["result"]["min_volume"].clone()).unwrap(); + assert_eq!(min_volume, min_volume_response); + + log!("Wait for 4 seconds for Bob order to be converted to maker"); + block_on(Timer::sleep(4.)); + + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "my_orders", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!my_orders: {}", rc.1); + let my_orders: Json = serde_json::from_str(&rc.1).unwrap(); + let my_maker_orders: HashMap = + serde_json::from_value(my_orders["result"]["maker_orders"].clone()).unwrap(); + let my_taker_orders: HashMap = + serde_json::from_value(my_orders["result"]["taker_orders"].clone()).unwrap(); + assert_eq!(1, my_maker_orders.len(), "maker_orders must have exactly 1 order"); + assert!(my_taker_orders.is_empty(), "taker_orders must be empty"); + let maker_order = my_maker_orders.get(&uuid).unwrap(); + let min_volume_maker: BigDecimal = serde_json::from_value(maker_order["min_base_vol"].clone()).unwrap(); + assert_eq!(min_volume, min_volume_maker); +} + +#[test] +fn test_setprice_min_volume_dust() { + let privkey = random_secp256k1_secret(); + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); + + let coins = json! ([ + {"coin":"MYCOIN","asset":"MYCOIN","txversion":4,"overwintered":1,"txfee":1000,"dust":10000000,"protocol":{"type":"UTXO"}}, + mycoin1_conf(1000), + ]); + + let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("MM log path: {}", mm.log_path.display()); + + // Enable coins + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + + log!("Issue bob MYCOIN/MYCOIN1 sell request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": "1", + "volume": "1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + let response: SetPriceResponse = serde_json::from_str(&rc.1).unwrap(); + let expected_min = BigDecimal::from(1); + assert_eq!(expected_min, response.result.min_base_vol); + + log!("Issue bob MYCOIN/MYCOIN1 sell request less than dust"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": "1", + // Less than dust, should fial + "volume": 0.01, + }))) + .unwrap(); + assert!(!rc.0.is_success(), "!setprice: {}", rc.1); +} + +#[test] +fn test_sell_min_volume_dust() { + let privkey = random_secp256k1_secret(); + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); + + let coins = json! ([ + {"coin":"MYCOIN","asset":"MYCOIN","txversion":4,"overwintered":1,"txfee":1000,"dust":10000000,"protocol":{"type":"UTXO"}}, + mycoin1_conf(1000), + ]); + + let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("MM log path: {}", mm.log_path.display()); + + // Enable coins + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + + log!("Issue bob MYCOIN/MYCOIN1 sell request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "sell", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": "1", + "volume": "1", + "order_type": { + "type": "FillOrKill" + } + }))) + .unwrap(); + assert!(rc.0.is_success(), "!sell: {}", rc.1); + let response: BuyOrSellRpcResult = serde_json::from_str(&rc.1).unwrap(); + let expected_min = BigDecimal::from(1); + assert_eq!(response.result.min_volume, expected_min); + + log!("Issue bob MYCOIN/MYCOIN1 sell request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "sell", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": "1", + // Less than dust + "volume": 0.01, + "order_type": { + "type": "FillOrKill" + } + }))) + .unwrap(); + assert!(!rc.0.is_success(), "!sell: {}", rc.1); +} + +// ============================================================================= +// P2P Infrastructure Tests +// These tests verify P2P networking behavior (UTXO-based, coin-agnostic) +// ============================================================================= + +#[test] +fn test_peer_time_sync_validation() { + let timeoffset_tolerable = TryInto::::try_into(MAX_TIME_GAP_FOR_CONNECTED_PEER).unwrap() - 1; + let timeoffset_too_big = TryInto::::try_into(MAX_TIME_GAP_FOR_CONNECTED_PEER).unwrap() + 1; + + let start_peers_with_time_offset = |offset: i64| -> (Json, Json) { + let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 10.into()); + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 10.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let bob_conf = Mm2TestConf::seednode(&hex::encode(bob_priv_key), &coins); + let mut mm_bob = block_on(MarketMakerIt::start_with_envs( + bob_conf.conf, + bob_conf.rpc_password, + None, + &[], + )) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + block_on(mm_bob.wait_for_log(22., |log| log.contains(">>>>>>>>> DEX stats "))).unwrap(); + let alice_conf = + Mm2TestConf::light_node(&hex::encode(alice_priv_key), &coins, &[mm_bob.ip.to_string().as_str()]); + let mut mm_alice = block_on(MarketMakerIt::start_with_envs( + alice_conf.conf, + alice_conf.rpc_password, + None, + &[("TEST_TIMESTAMP_OFFSET", offset.to_string().as_str())], + )) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + block_on(mm_alice.wait_for_log(22., |log| log.contains(">>>>>>>>> DEX stats "))).unwrap(); + + let res_bob = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "get_directly_connected_peers", + }))) + .unwrap(); + assert!(res_bob.0.is_success(), "!get_directly_connected_peers: {}", res_bob.1); + let bob_peers = serde_json::from_str::(&res_bob.1).unwrap(); + + let res_alice = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "get_directly_connected_peers", + }))) + .unwrap(); + assert!( + res_alice.0.is_success(), + "!get_directly_connected_peers: {}", + res_alice.1 + ); + let alice_peers = serde_json::from_str::(&res_alice.1).unwrap(); + + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); + (bob_peers, alice_peers) + }; + + // check with small time offset: + let (bob_peers, alice_peers) = start_peers_with_time_offset(timeoffset_tolerable); + assert!( + bob_peers["result"].as_object().unwrap().len() == 1, + "bob must have one peer" + ); + assert!( + alice_peers["result"].as_object().unwrap().len() == 1, + "alice must have one peer" + ); + + // check with too big time offset: + let (bob_peers, alice_peers) = start_peers_with_time_offset(timeoffset_too_big); + assert!( + bob_peers["result"].as_object().unwrap().is_empty(), + "bob must have no peers" + ); + assert!( + alice_peers["result"].as_object().unwrap().is_empty(), + "alice must have no peers" + ); +} diff --git a/mm2src/mm2_main/tests/docker_tests/utxo_swaps_v1_tests.rs b/mm2src/mm2_main/tests/docker_tests/utxo_swaps_v1_tests.rs new file mode 100644 index 0000000000..843d5d1950 --- /dev/null +++ b/mm2src/mm2_main/tests/docker_tests/utxo_swaps_v1_tests.rs @@ -0,0 +1,2308 @@ +// UTXO Swaps V1 Tests +// +// This module contains UTXO-only swap tests that were extracted from docker_tests_inner.rs +// These tests focus on UTXO swap mechanics, payment lifecycle, and related functionality. +// They do NOT require ETH/ERC20 containers - only MYCOIN/MYCOIN1 UTXO containers. +// +// Gated by: docker-tests-swaps + +use crate::docker_tests::helpers::env::random_secp256k1_secret; +use crate::docker_tests::helpers::swap::trade_base_rel; +use crate::docker_tests::helpers::utxo::{ + fill_address, generate_utxo_coin_with_privkey, generate_utxo_coin_with_random_privkey, rmd160_from_priv, + utxo_coin_from_privkey, +}; +use crate::integration_tests_common::*; +use bitcrypto::dhash160; +use chain::OutPoint; +use coins::utxo::rpc_clients::UnspentInfo; +use coins::utxo::{GetUtxoListOps, UtxoCommonOps}; +use coins::{ + ConfirmPaymentInput, FoundSwapTxSpend, MarketCoinOps, MmCoin, RefundPaymentArgs, SearchForSwapTxSpendInput, + SendPaymentArgs, SpendPaymentArgs, SwapOps, SwapTxTypeWithSecretHash, TransactionEnum, +}; +use common::{block_on, block_on_f01, executor::Timer, now_sec, wait_until_sec}; +use mm2_number::{BigDecimal, MmNumber}; +use mm2_test_helpers::for_tests::{ + get_locked_amount, kmd_conf, max_maker_vol, mm_dump, mycoin1_conf, mycoin_conf, set_price, start_swaps, + MarketMakerIt, Mm2TestConf, +}; +use mm2_test_helpers::structs::*; +use serde_json::{json, Value as Json}; +use std::collections::HashMap; +use std::str::FromStr; +use std::thread; +use std::time::Duration; + +// ============================================================================= +// UTXO Swap Spend/Refund Mechanics Tests +// Tests for searching swap tx spend status (refunded vs spent) +// ============================================================================= + +#[test] +fn test_search_for_swap_tx_spend_native_was_refunded_taker() { + let timeout = wait_until_sec(120); + let (_ctx, coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); + let my_public_key = coin.my_public_key().unwrap(); + + let time_lock = now_sec() - 3600; + let taker_payment_args = SendPaymentArgs { + time_lock_duration: 0, + time_lock, + other_pubkey: &my_public_key, + secret_hash: &[0; 20], + amount: 1u64.into(), + swap_contract_address: &None, + swap_unique_data: &[], + payment_instructions: &None, + watcher_reward: None, + wait_for_confirmation_until: 0, + }; + let tx = block_on(coin.send_taker_payment(taker_payment_args)).unwrap(); + + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: tx.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + let maker_refunds_payment_args = RefundPaymentArgs { + payment_tx: &tx.tx_hex(), + time_lock, + other_pubkey: &my_public_key, + tx_type_with_secret_hash: SwapTxTypeWithSecretHash::TakerOrMakerPayment { + maker_secret_hash: &[0; 20], + }, + swap_contract_address: &None, + swap_unique_data: &[], + watcher_reward: false, + }; + let refund_tx = block_on(coin.send_maker_refunds_payment(maker_refunds_payment_args)).unwrap(); + + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: refund_tx.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + + let pubkey = coin.my_public_key().unwrap(); + let search_input = SearchForSwapTxSpendInput { + time_lock, + other_pub: &pubkey, + secret_hash: &[0; 20], + tx: &tx.tx_hex(), + search_from_block: 0, + swap_contract_address: &None, + swap_unique_data: &[], + }; + let found = block_on(coin.search_for_swap_tx_spend_my(search_input)) + .unwrap() + .unwrap(); + assert_eq!(FoundSwapTxSpend::Refunded(refund_tx), found); +} + +#[test] +fn test_for_non_existent_tx_hex_utxo() { + // This test shouldn't wait till timeout! + let timeout = wait_until_sec(120); + let (_ctx, coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); + // bad transaction hex + let tx = hex::decode("0400008085202f8902bf17bf7d1daace52e08f732a6b8771743ca4b1cb765a187e72fd091a0aabfd52000000006a47304402203eaaa3c4da101240f80f9c5e9de716a22b1ec6d66080de6a0cca32011cd77223022040d9082b6242d6acf9a1a8e658779e1c655d708379862f235e8ba7b8ca4e69c6012102031d4256c4bc9f99ac88bf3dba21773132281f65f9bf23a59928bce08961e2f3ffffffffff023ca13c0e9e085dd13f481f193e8a3e8fd609020936e98b5587342d994f4d020000006b483045022100c0ba56adb8de923975052312467347d83238bd8d480ce66e8b709a7997373994022048507bcac921fdb2302fa5224ce86e41b7efc1a2e20ae63aa738dfa99b7be826012102031d4256c4bc9f99ac88bf3dba21773132281f65f9bf23a59928bce08961e2f3ffffffff0300e1f5050000000017a9141ee6d4c38a3c078eab87ad1a5e4b00f21259b10d87000000000000000016611400000000000000000000000000000000000000001b94d736000000001976a91405aab5342166f8594baf17a7d9bef5d56744332788ac2d08e35e000000000000000000000000000000").unwrap(); + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: tx, + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + let actual = block_on_f01(coin.wait_for_confirmations(confirm_payment_input)) + .err() + .unwrap(); + assert!(actual.contains( + "Tx d342ff9da528a2e262bddf2b6f9a27d1beb7aeb03f0fc8d9eac2987266447e44 was not found on chain after 10 tries" + )); +} + +#[test] +fn test_search_for_swap_tx_spend_native_was_refunded_maker() { + let timeout = wait_until_sec(120); + let (_ctx, coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); + let my_public_key = coin.my_public_key().unwrap(); + + let time_lock = now_sec() - 3600; + let maker_payment_args = SendPaymentArgs { + time_lock_duration: 0, + time_lock, + other_pubkey: &my_public_key, + secret_hash: &[0; 20], + amount: 1u64.into(), + swap_contract_address: &None, + swap_unique_data: &[], + payment_instructions: &None, + watcher_reward: None, + wait_for_confirmation_until: 0, + }; + let tx = block_on(coin.send_maker_payment(maker_payment_args)).unwrap(); + + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: tx.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + let maker_refunds_payment_args = RefundPaymentArgs { + payment_tx: &tx.tx_hex(), + time_lock, + other_pubkey: &my_public_key, + tx_type_with_secret_hash: SwapTxTypeWithSecretHash::TakerOrMakerPayment { + maker_secret_hash: &[0; 20], + }, + swap_contract_address: &None, + swap_unique_data: &[], + watcher_reward: false, + }; + let refund_tx = block_on(coin.send_maker_refunds_payment(maker_refunds_payment_args)).unwrap(); + + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: refund_tx.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + + let pubkey = coin.my_public_key().unwrap(); + let search_input = SearchForSwapTxSpendInput { + time_lock, + other_pub: &pubkey, + secret_hash: &[0; 20], + tx: &tx.tx_hex(), + search_from_block: 0, + swap_contract_address: &None, + swap_unique_data: &[], + }; + let found = block_on(coin.search_for_swap_tx_spend_my(search_input)) + .unwrap() + .unwrap(); + assert_eq!(FoundSwapTxSpend::Refunded(refund_tx), found); +} + +#[test] +fn test_search_for_taker_swap_tx_spend_native_was_spent_by_maker() { + let timeout = wait_until_sec(120); + let (_ctx, coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); + let secret = [0; 32]; + let my_pubkey = coin.my_public_key().unwrap(); + + let secret_hash = dhash160(&secret); + let time_lock = now_sec() - 3600; + let taker_payment_args = SendPaymentArgs { + time_lock_duration: 0, + time_lock, + other_pubkey: &my_pubkey, + secret_hash: secret_hash.as_slice(), + amount: 1u64.into(), + swap_contract_address: &None, + swap_unique_data: &[], + payment_instructions: &None, + watcher_reward: None, + wait_for_confirmation_until: 0, + }; + let tx = block_on(coin.send_taker_payment(taker_payment_args)).unwrap(); + + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: tx.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + let maker_spends_payment_args = SpendPaymentArgs { + other_payment_tx: &tx.tx_hex(), + time_lock, + other_pubkey: &my_pubkey, + secret: &secret, + secret_hash: secret_hash.as_slice(), + swap_contract_address: &None, + swap_unique_data: &[], + watcher_reward: false, + }; + let spend_tx = block_on(coin.send_maker_spends_taker_payment(maker_spends_payment_args)).unwrap(); + + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: spend_tx.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + + let pubkey = coin.my_public_key().unwrap(); + let search_input = SearchForSwapTxSpendInput { + time_lock, + other_pub: &pubkey, + secret_hash: &*dhash160(&secret), + tx: &tx.tx_hex(), + search_from_block: 0, + swap_contract_address: &None, + swap_unique_data: &[], + }; + let found = block_on(coin.search_for_swap_tx_spend_my(search_input)) + .unwrap() + .unwrap(); + assert_eq!(FoundSwapTxSpend::Spent(spend_tx), found); +} + +#[test] +fn test_search_for_maker_swap_tx_spend_native_was_spent_by_taker() { + let timeout = wait_until_sec(120); + let (_ctx, coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000u64.into()); + let secret = [0; 32]; + let my_pubkey = coin.my_public_key().unwrap(); + + let time_lock = now_sec() - 3600; + let secret_hash = dhash160(&secret); + let maker_payment_args = SendPaymentArgs { + time_lock_duration: 0, + time_lock, + other_pubkey: &my_pubkey, + secret_hash: secret_hash.as_slice(), + amount: 1u64.into(), + swap_contract_address: &None, + swap_unique_data: &[], + payment_instructions: &None, + watcher_reward: None, + wait_for_confirmation_until: 0, + }; + let tx = block_on(coin.send_maker_payment(maker_payment_args)).unwrap(); + + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: tx.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + let taker_spends_payment_args = SpendPaymentArgs { + other_payment_tx: &tx.tx_hex(), + time_lock, + other_pubkey: &my_pubkey, + secret: &secret, + secret_hash: secret_hash.as_slice(), + swap_contract_address: &None, + swap_unique_data: &[], + watcher_reward: false, + }; + let spend_tx = block_on(coin.send_taker_spends_maker_payment(taker_spends_payment_args)).unwrap(); + + let confirm_payment_input = ConfirmPaymentInput { + payment_tx: spend_tx.tx_hex(), + confirmations: 1, + requires_nota: false, + wait_until: timeout, + check_every: 1, + }; + block_on_f01(coin.wait_for_confirmations(confirm_payment_input)).unwrap(); + + let pubkey = coin.my_public_key().unwrap(); + let search_input = SearchForSwapTxSpendInput { + time_lock, + other_pub: &pubkey, + secret_hash: &*dhash160(&secret), + tx: &tx.tx_hex(), + search_from_block: 0, + swap_contract_address: &None, + swap_unique_data: &[], + }; + let found = block_on(coin.search_for_swap_tx_spend_my(search_input)) + .unwrap() + .unwrap(); + assert_eq!(FoundSwapTxSpend::Spent(spend_tx), found); +} + +#[test] +fn test_one_hundred_maker_payments_in_a_row_native() { + let timeout = 30; + let (_ctx, coin, _) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); + let secret = [0; 32]; + let my_pubkey = coin.my_public_key().unwrap(); + + let time_lock = now_sec() - 3600; + let mut unspents = vec![]; + let mut sent_tx = vec![]; + for i in 0..100 { + let maker_payment_args = SendPaymentArgs { + time_lock_duration: 0, + time_lock: time_lock + i, + other_pubkey: &my_pubkey, + secret_hash: &*dhash160(&secret), + amount: 1.into(), + swap_contract_address: &coin.swap_contract_address(), + swap_unique_data: &[], + payment_instructions: &None, + watcher_reward: None, + wait_for_confirmation_until: 0, + }; + let tx = block_on(coin.send_maker_payment(maker_payment_args)).unwrap(); + if let TransactionEnum::UtxoTx(tx) = tx { + unspents.push(UnspentInfo { + outpoint: OutPoint { + hash: tx.hash(), + index: 2, + }, + value: tx.outputs[2].value, + height: None, + script: coin + .script_for_address(&block_on(coin.as_ref().derivation_method.unwrap_single_addr())) + .unwrap(), + }); + sent_tx.push(tx); + } + } + + let recently_sent = block_on(coin.as_ref().recently_spent_outpoints.lock()); + + unspents = recently_sent + .replace_spent_outputs_with_cache(unspents.into_iter().collect()) + .into_iter() + .collect(); + + let last_tx = sent_tx.last().unwrap(); + let expected_unspent = UnspentInfo { + outpoint: OutPoint { + hash: last_tx.hash(), + index: 2, + }, + value: last_tx.outputs[2].value, + height: None, + script: last_tx.outputs[2].script_pubkey.clone().into(), + }; + assert_eq!(vec![expected_unspent], unspents); +} + +// ============================================================================= +// UTXO-only Swap and Trade Tests +// Tests for complete swap flows using only MYCOIN/MYCOIN1 +// ============================================================================= + +#[test] +fn test_trade_base_rel_mycoin_mycoin1_coins() { + trade_base_rel(("MYCOIN", "MYCOIN1")); +} + +#[test] +fn test_trade_base_rel_mycoin_mycoin1_coins_burnkey_as_alice() { + // Trade with burn pubkey set as Alice's pubkey (for testing purposes) + // Uses the SET_BURN_PUBKEY_TO_ALICE flag via trade_base_rel + use crate::docker_tests::helpers::env::SET_BURN_PUBKEY_TO_ALICE; + SET_BURN_PUBKEY_TO_ALICE.set(true); + trade_base_rel(("MYCOIN", "MYCOIN1")); + SET_BURN_PUBKEY_TO_ALICE.set(false); +} + +// ============================================================================= +// Max Volume Tests +// Tests for max_taker_vol and max_maker_vol RPCs +// ============================================================================= + +#[test] +fn test_get_max_taker_vol() { + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "max_taker_vol", + "coin": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!max_taker_vol: {}", rc.1); + let json: MaxTakerVolResponse = serde_json::from_str(&rc.1).unwrap(); + // With 2% fee rate: max_vol = (balance - tx_fee) / 1.02 + // balance = 1, tx_fee varies based on UTXO, so max_vol β‰ˆ 0.98 + let expected = MmNumber::from((99999481u64, 102000000u64)).to_fraction(); + assert_eq!(json.result, expected); + assert_eq!(json.coin, "MYCOIN1"); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "sell", + "base": "MYCOIN1", + "rel": "MYCOIN", + "price": 1, + "volume": json.result, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!sell: {}", rc.1); + + block_on(mm_alice.stop()).unwrap(); +} + +#[test] +fn test_get_max_taker_vol_dex_fee_min_tx_amount() { + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", "0.00532845".parse().unwrap()); + let coins = json!([mycoin_conf(10000), mycoin1_conf(10000)]); + let mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "max_taker_vol", + "coin": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!max_taker_vol: {}", rc.1); + let json: Json = serde_json::from_str(&rc.1).unwrap(); + // With 2% fee rate: max_vol = (balance - tx_fee) / 1.02 + // balance = 0.00532845, tx_fee varies based on UTXO + assert_eq!(json["result"]["numer"], Json::from("35177")); + assert_eq!(json["result"]["denom"], Json::from("6800000")); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "sell", + "base": "MYCOIN1", + "rel": "MYCOIN", + "price": 1, + "volume": { + "numer": json["result"]["numer"], + "denom": json["result"]["denom"], + } + }))) + .unwrap(); + assert!(rc.0.is_success(), "!sell: {}", rc.1); + + block_on(mm_alice.stop()).unwrap(); +} + +#[test] +fn test_get_max_taker_vol_dust_threshold() { + let (_ctx, coin, priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", "0.0014041".parse().unwrap()); + let coins = json!([ + mycoin_conf(10000), + {"coin":"MYCOIN1","asset":"MYCOIN1","txversion":4,"overwintered":1,"txfee":10000,"protocol":{"type":"UTXO"},"dust":72800} + ]); + let mm = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm.log_path); + + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "method": "max_taker_vol", + "coin": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!max_taker_vol {}", rc.1); + let json: Json = serde_json::from_str(&rc.1).unwrap(); + let result: MmNumber = serde_json::from_value(json["result"].clone()).unwrap(); + assert!(result.is_zero()); + + fill_address(&coin, &coin.my_address().unwrap(), "0.0002".parse().unwrap(), 30); + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "method": "max_taker_vol", + "coin": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!max_taker_vol: {}", rc.1); + let json: Json = serde_json::from_str(&rc.1).unwrap(); + assert_eq!(json["result"]["numer"], Json::from("3973")); + assert_eq!(json["result"]["denom"], Json::from("5000000")); + + block_on(mm.stop()).unwrap(); +} + +#[test] +fn test_get_max_taker_vol_with_kmd() { + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1.into()); + let coins = json!([mycoin_conf(10000), mycoin1_conf(10000), kmd_conf(10000)]); + let mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + let electrum = block_on(enable_electrum( + &mm_alice, + "KMD", + false, + &[ + "electrum1.cipig.net:10001", + "electrum2.cipig.net:10001", + "electrum3.cipig.net:10001", + ], + )); + log!("{:?}", electrum); + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "max_taker_vol", + "coin": "MYCOIN1", + "trade_with": "KMD", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!max_taker_vol: {}", rc.1); + let json: Json = serde_json::from_str(&rc.1).unwrap(); + // With 2% fee rate (KMD discount removed): max_vol = (balance - tx_fee) / 1.02 + // balance = 1, tx_fee varies based on UTXO, so max_vol β‰ˆ 0.9999 + assert_eq!(json["result"]["numer"], Json::from("9999481")); + assert_eq!(json["result"]["denom"], Json::from("10200000")); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "sell", + "base": "MYCOIN1", + "rel": "KMD", + "price": 1, + "volume": { + "numer": json["result"]["numer"], + "denom": json["result"]["denom"], + } + }))) + .unwrap(); + assert!(rc.0.is_success(), "!sell: {}", rc.1); + + block_on(mm_alice.stop()).unwrap(); +} + +#[test] +fn test_get_max_maker_vol() { + let (_ctx, _, priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(priv_key)), &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + let (_dump_log, _dump_dashboard) = mm_dump(&mm.log_path); + + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + + let expected_volume = MmNumber::from("0.99999726"); + let expected = MaxMakerVolResponse { + coin: "MYCOIN1".to_string(), + volume: MmNumberMultiRepr::from(expected_volume.clone()), + balance: MmNumberMultiRepr::from(1), + locked_by_swaps: MmNumberMultiRepr::from(0), + }; + let actual = block_on(max_maker_vol(&mm, "MYCOIN1")).unwrap::(); + assert_eq!(actual, expected); + + let res = block_on(set_price(&mm, "MYCOIN1", "MYCOIN", "1", "0", true, None)); + assert_eq!(res.result.max_base_vol, expected_volume.to_decimal()); +} + +#[test] +fn test_get_max_maker_vol_error() { + let priv_key = random_secp256k1_secret(); + let coins = json!([mycoin_conf(1000)]); + let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(priv_key)), &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + let (_dump_log, _dump_dashboard) = mm_dump(&mm.log_path); + + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + + let actual_error = block_on(max_maker_vol(&mm, "MYCOIN")).unwrap_err::(); + let expected_error = max_maker_vol_error::NotSufficientBalance { + coin: "MYCOIN".to_owned(), + available: 0.into(), + required: BigDecimal::from(1000) / BigDecimal::from(100_000_000), + locked_by_swaps: None, + }; + assert_eq!(actual_error.error_type, "NotSufficientBalance"); + assert_eq!(actual_error.error_data, Some(expected_error)); +} + +// ============================================================================= +// UTXO Merge and Consolidation Tests +// Tests for UTXO merge functionality and consolidate_utxos RPC +// ============================================================================= + +#[test] +fn test_utxo_merge() { + let timeout = 30; + let (_ctx, coin, privkey) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); + fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); + fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); + fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(privkey)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let native = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "enable", + "coin": "MYCOIN", + "mm2": 1, + "utxo_merge_params": { + "merge_at": 2, + "check_every": 1, + } + }))) + .unwrap(); + assert!(native.0.is_success(), "'enable' failed: {}", native.1); + log!("Enable result {}", native.1); + + block_on(mm_bob.wait_for_log(4., |log| log.contains("Starting UTXO merge loop for coin MYCOIN"))).unwrap(); + + block_on(mm_bob.wait_for_log(4., |log| { + log.contains("UTXO merge of 5 outputs successful for coin=MYCOIN, tx_hash") + })) + .unwrap(); + + thread::sleep(Duration::from_secs(2)); + let address = block_on(coin.as_ref().derivation_method.unwrap_single_addr()); + let (unspents, _) = block_on(coin.get_unspent_ordered_list(&address)).unwrap(); + assert_eq!(unspents.len(), 1); +} + +#[test] +fn test_utxo_merge_max_merge_at_once() { + let timeout = 30; + let (_ctx, coin, privkey) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); + fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); + fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); + fill_address(&coin, &coin.my_address().unwrap(), 2.into(), timeout); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(privkey)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let native = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "enable", + "coin": "MYCOIN", + "mm2": 1, + "utxo_merge_params": { + "merge_at": 3, + "check_every": 1, + "max_merge_at_once": 4, + } + }))) + .unwrap(); + assert!(native.0.is_success(), "'enable' failed: {}", native.1); + log!("Enable result {}", native.1); + + block_on(mm_bob.wait_for_log(4., |log| log.contains("Starting UTXO merge loop for coin MYCOIN"))).unwrap(); + + block_on(mm_bob.wait_for_log(4., |log| { + log.contains("UTXO merge of 4 outputs successful for coin=MYCOIN, tx_hash") + })) + .unwrap(); + + thread::sleep(Duration::from_secs(2)); + let address = block_on(coin.as_ref().derivation_method.unwrap_single_addr()); + let (unspents, _) = block_on(coin.get_unspent_ordered_list(&address)).unwrap(); + assert_eq!(unspents.len(), 2); +} + +#[test] +fn test_consolidate_utxos_rpc() { + let timeout = 30; + let utxos = 50; + let (_ctx, coin, privkey) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + + for i in 1..=utxos { + fill_address(&coin, &coin.my_address().unwrap(), i.into(), timeout); + } + + let coins = json!([mycoin_conf(1000)]); + let mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(privkey)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + + let consolidate_rpc = |merge_at: u32, merge_at_once: u32| { + block_on(mm_bob.rpc(&json!({ + "mmrpc": "2.0", + "userpass": mm_bob.userpass, + "method": "consolidate_utxos", + "params": { + "coin": "MYCOIN", + "merge_conditions": { + "merge_at": merge_at, + "max_merge_at_once": merge_at_once, + }, + "broadcast": true + } + }))) + .unwrap() + }; + + let res = consolidate_rpc(52, 4); + assert!(!res.0.is_success(), "Expected error for merge_at > utxos: {}", res.1); + + let res = consolidate_rpc(30, 4); + assert!(res.0.is_success(), "Consolidate utxos failed: {}", res.1); + + let res: RpcSuccessResponse = + serde_json::from_str(&res.1).expect("Expected 'RpcSuccessResponse'"); + assert_eq!(res.result.consolidated_utxos.len(), 4); + for i in 1..=4 { + assert_eq!(res.result.consolidated_utxos[i - 1].value, (i as u32).into()); + } + + thread::sleep(Duration::from_secs(2)); + let address = block_on(coin.as_ref().derivation_method.unwrap_single_addr()); + let (unspents, _) = block_on(coin.get_unspent_ordered_list(&address)).unwrap(); + assert_eq!(unspents.len(), 51 - 4 + 1); +} + +#[test] +fn test_fetch_utxos_rpc() { + let timeout = 30; + let (_ctx, coin, privkey) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + + for i in 1..=10 { + fill_address(&coin, &coin.my_address().unwrap(), i.into(), timeout); + } + + let coins = json!([mycoin_conf(1000)]); + let mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(privkey)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + + let fetch_utxo_rpc = || { + let res = block_on(mm_bob.rpc(&json!({ + "mmrpc": "2.0", + "userpass": mm_bob.userpass, + "method": "fetch_utxos", + "params": { + "coin": "MYCOIN" + } + }))) + .unwrap(); + assert!(res.0.is_success(), "Fetch UTXOs failed: {}", res.1); + let res: RpcSuccessResponse = + serde_json::from_str(&res.1).expect("Expected 'RpcSuccessResponse'"); + res.result + }; + + let res = fetch_utxo_rpc(); + assert!(res.total_count == 11); + + fill_address(&coin, &coin.my_address().unwrap(), 100.into(), timeout); + thread::sleep(Duration::from_secs(2)); + + let res = fetch_utxo_rpc(); + assert!(res.total_count == 12); + assert!(res.addresses[0].utxos.iter().any(|utxo| utxo.value == 100.into())); +} + +// ============================================================================= +// Withdraw Tests (UTXO-only) +// Tests for withdraw RPC with insufficient balance +// ============================================================================= + +#[test] +fn test_withdraw_not_sufficient_balance() { + let privkey = random_secp256k1_secret(); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mm = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(privkey)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm.log_path); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + + let amount = BigDecimal::from(1); + let withdraw = block_on(mm.rpc(&json!({ + "mmrpc": "2.0", + "userpass": mm.userpass, + "method": "withdraw", + "params": { + "coin": "MYCOIN", + "to": "RJTYiYeJ8eVvJ53n2YbrVmxWNNMVZjDGLh", + "amount": amount, + }, + "id": 0, + }))) + .unwrap(); + + assert!(withdraw.0.is_client_error(), "MYCOIN withdraw: {}", withdraw.1); + log!("error: {:?}", withdraw.1); + let error: RpcErrorResponse = + serde_json::from_str(&withdraw.1).expect("Expected 'RpcErrorResponse'"); + let expected_error = withdraw_error::NotSufficientBalance { + coin: "MYCOIN".to_owned(), + available: 0.into(), + required: amount, + }; + assert_eq!(error.error_type, "NotSufficientBalance"); + assert_eq!(error.error_data, Some(expected_error)); + + let balance = BigDecimal::from(1) / BigDecimal::from(2); + let (_ctx, coin) = utxo_coin_from_privkey("MYCOIN", privkey); + fill_address(&coin, &coin.my_address().unwrap(), balance.clone(), 30); + + let txfee = BigDecimal::from_str("0.00000211").unwrap(); + let withdraw = block_on(mm.rpc(&json!({ + "mmrpc": "2.0", + "userpass": mm.userpass, + "method": "withdraw", + "params": { + "coin": "MYCOIN", + "to": "RJTYiYeJ8eVvJ53n2YbrVmxWNNMVZjDGLh", + "amount": balance, + }, + "id": 0, + }))) + .unwrap(); + + assert!(withdraw.0.is_client_error(), "MYCOIN withdraw: {}", withdraw.1); + log!("error: {:?}", withdraw.1); + let error: RpcErrorResponse = + serde_json::from_str(&withdraw.1).expect("Expected 'RpcErrorResponse'"); + let expected_error = withdraw_error::NotSufficientBalance { + coin: "MYCOIN".to_owned(), + available: balance.clone(), + required: balance + txfee, + }; + assert_eq!(error.error_type, "NotSufficientBalance"); + assert_eq!(error.error_data, Some(expected_error)); +} + +// ============================================================================= +// Locked Amount Tests +// Tests for locked_amount RPC during swaps +// ============================================================================= + +#[test] +fn test_locked_amount() { + let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1000.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let bob_conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(bob_priv_key)), &coins); + let mut mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let alice_conf = Mm2TestConf::light_node( + &format!("0x{}", hex::encode(alice_priv_key)), + &coins, + &[&mm_bob.ip.to_string()], + ); + let mut mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + + block_on(start_swaps( + &mut mm_bob, + &mut mm_alice, + &[("MYCOIN", "MYCOIN1")], + 1., + 1., + 777., + )); + + let locked_bob = block_on(get_locked_amount(&mm_bob, "MYCOIN")); + assert_eq!(locked_bob.coin, "MYCOIN"); + + let expected_result: MmNumberMultiRepr = MmNumber::from("777.00000274").into(); + assert_eq!(expected_result, locked_bob.locked_amount); + + let locked_alice = block_on(get_locked_amount(&mm_alice, "MYCOIN1")); + assert_eq!(locked_alice.coin, "MYCOIN1"); + + // With 2% fee rate: locked = volume + dex_fee + tx_fees + // = 777 + (777 * 0.02) + 0.00000519 = 777 + 15.54 + 0.00000519 = 792.54000519 + let expected_result: MmNumberMultiRepr = MmNumber::from("792.54000519").into(); + assert_eq!(expected_result, locked_alice.locked_amount); +} + +// ============================================================================= +// Swap Lifecycle Tests +// Tests for swap stopping, order transformation, etc. +// ============================================================================= + +#[test] +fn swaps_should_stop_on_stop_rpc() { + let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1000.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let bob_conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(bob_priv_key)), &coins); + let mut mm_bob = MarketMakerIt::start(bob_conf.conf, bob_conf.rpc_password, None).unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let alice_conf = Mm2TestConf::light_node( + &format!("0x{}", hex::encode(alice_priv_key)), + &coins, + &[&mm_bob.ip.to_string()], + ); + let mut mm_alice = MarketMakerIt::start(alice_conf.conf, alice_conf.rpc_password, None).unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + + block_on(start_swaps( + &mut mm_bob, + &mut mm_alice, + &[("MYCOIN", "MYCOIN1")], + 1., + 1., + 0.0001, + )); + + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} + +#[test] +fn test_fill_or_kill_taker_order_should_not_transform_to_maker() { + let privkey = random_secp256k1_secret(); + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); + + let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("MM log path: {}", mm.log_path.display()); + + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + + log!("Issue bob MYCOIN/MYCOIN1 sell request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "sell", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": 0.1, + "order_type": { + "type": "FillOrKill" + }, + "timeout": 2, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!sell: {}", rc.1); + let sell_json: Json = serde_json::from_str(&rc.1).unwrap(); + let order_type = sell_json["result"]["order_type"]["type"].as_str(); + assert_eq!(order_type, Some("FillOrKill")); + + log!("Wait for 4 seconds for Bob order to be cancelled"); + block_on(Timer::sleep(4.)); + + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "my_orders", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!my_orders: {}", rc.1); + let my_orders: Json = serde_json::from_str(&rc.1).unwrap(); + let my_maker_orders: HashMap = + serde_json::from_value(my_orders["result"]["maker_orders"].clone()).unwrap(); + let my_taker_orders: HashMap = + serde_json::from_value(my_orders["result"]["taker_orders"].clone()).unwrap(); + assert!(my_maker_orders.is_empty(), "maker_orders must be empty"); + assert!(my_taker_orders.is_empty(), "taker_orders must be empty"); +} + +#[test] +fn test_gtc_taker_order_should_transform_to_maker() { + let privkey = random_secp256k1_secret(); + generate_utxo_coin_with_privkey("MYCOIN", 1000.into(), privkey); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000),]); + + let conf = Mm2TestConf::seednode(&format!("0x{}", hex::encode(privkey)), &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let (_mm_dump_log, _mm_dump_dashboard) = mm.mm_dump(); + log!("MM log path: {}", mm.log_path.display()); + + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + + log!("Issue bob MYCOIN/MYCOIN1 sell request"); + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "sell", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": 0.1, + "order_type": { + "type": "GoodTillCancelled" + }, + "timeout": 2, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + let rc_json: Json = serde_json::from_str(&rc.1).unwrap(); + let uuid: String = serde_json::from_value(rc_json["result"]["uuid"].clone()).unwrap(); + + log!("Wait for 4 seconds for Bob order to be converted to maker"); + block_on(Timer::sleep(4.)); + + let rc = block_on(mm.rpc(&json! ({ + "userpass": mm.userpass, + "method": "my_orders", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!my_orders: {}", rc.1); + let my_orders: Json = serde_json::from_str(&rc.1).unwrap(); + let my_maker_orders: HashMap = + serde_json::from_value(my_orders["result"]["maker_orders"].clone()).unwrap(); + let my_taker_orders: HashMap = + serde_json::from_value(my_orders["result"]["taker_orders"].clone()).unwrap(); + assert_eq!( + 1, + my_maker_orders.len(), + "maker_orders must have exactly 1 order, but has {:?}", + my_maker_orders + ); + assert!(my_taker_orders.is_empty(), "taker_orders must be empty"); + assert!(my_maker_orders.contains_key(&uuid)); +} + +// ============================================================================= +// Buy/Sell with Locked Coins Tests +// Tests for order placement when coins are locked by other swaps +// ============================================================================= + +#[test] +fn test_buy_when_coins_locked_by_other_swap() { + let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 2.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let mut mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "max": true, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + // With 2% fee rate: max_vol = (balance - tx_fee) / 1.02 = 49999863/51000000 + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": { + "numer":"49999863", + "denom":"51000000" + }, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + + block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + thread::sleep(Duration::from_secs(6)); + + // Second buy should fail because coins are locked by first swap + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": { + "numer":"49999864", + "denom":"51000000" + }, + }))) + .unwrap(); + assert!(!rc.0.is_success(), "buy success, but should fail: {}", rc.1); + assert!(rc.1.contains("Not enough MYCOIN1 for swap"), "{}", rc.1); + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} + +#[test] +fn test_sell_when_coins_locked_by_other_swap() { + let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 2.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let mut mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "max": true, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + // With 2% fee rate: max_vol = (balance - tx_fee) / 1.02 = 49999863/51000000 + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "sell", + "base": "MYCOIN1", + "rel": "MYCOIN", + "price": 1, + "volume": { + "numer":"49999863", + "denom":"51000000" + }, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!sell: {}", rc.1); + + block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + thread::sleep(Duration::from_secs(6)); + + // Second sell should fail because coins are locked by first swap + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "sell", + "base": "MYCOIN1", + "rel": "MYCOIN", + "price": 1, + "volume": { + "numer":"49999864", + "denom":"51000000" + }, + }))) + .unwrap(); + assert!(!rc.0.is_success(), "sell success, but should fail: {}", rc.1); + assert!(rc.1.contains("Not enough MYCOIN1 for swap"), "{}", rc.1); + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} + +#[test] +fn test_buy_max() { + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 1.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + // With 2% fee rate: max_vol = (balance - tx_fee) / 1.02 = 99999481/102000000 + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": { + "numer":"99999481", + "denom":"102000000" + }, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + + // Slightly more than max should fail + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": { + "numer":"99999482", + "denom":"102000000" + }, + }))) + .unwrap(); + assert!(!rc.0.is_success(), "buy success, but should fail: {}", rc.1); + block_on(mm_alice.stop()).unwrap(); +} + +// ============================================================================= +// Setprice Max Volume Tests +// Tests for setprice with max parameter and volume calculations +// ============================================================================= + +#[test] +// https://github.com/KomodoPlatform/atomicDEX-API/issues/471 +fn test_match_and_trade_setprice_max() { + let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 2000.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut mm_bob = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + + let mut mm_alice = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "max": true, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + let json: Json = serde_json::from_str(&rc.1).unwrap(); + let bob_uuid = json["result"]["uuid"].as_str().unwrap().to_owned(); + + log!("Get MYCOIN/MYCOIN1 orderbook"); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "orderbook", + "base": "MYCOIN", + "rel": "MYCOIN1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + + let bob_orderbook: Json = serde_json::from_str(&rc.1).unwrap(); + log!("orderbook {:?}", bob_orderbook); + let asks = bob_orderbook["asks"].as_array().unwrap(); + assert_eq!(asks.len(), 1, "MYCOIN/MYCOIN1 orderbook must have exactly 1 ask"); + assert_eq!(asks[0]["maxvolume"], Json::from("999.99999726")); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "buy", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": 1, + "volume": "999.99999", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!buy: {}", rc.1); + + block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + + thread::sleep(Duration::from_secs(3)); + + let rmd160 = rmd160_from_priv(bob_priv_key); + let order_path = mm_bob.folder.join(format!( + "DB/{}/ORDERS/MY/MAKER/{}.json", + hex::encode(rmd160.take()), + bob_uuid, + )); + log!("Order path {}", order_path.display()); + assert!(!order_path.exists()); + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} + +#[test] +// https://github.com/KomodoPlatform/atomicDEX-API/issues/888 +fn test_max_taker_vol_swap() { + let (_ctx, _, bob_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN", 1000.into()); + let (_ctx, _, alice_priv_key) = generate_utxo_coin_with_random_privkey("MYCOIN1", 50.into()); + let coins = json!([mycoin_conf(1000), mycoin1_conf(1000)]); + let mut mm_bob = block_on(MarketMakerIt::start_with_envs( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(bob_priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + &[("MYCOIN_FEE_DISCOUNT", "")], + )) + .unwrap(); + let (_bob_dump_log, _bob_dump_dashboard) = mm_dump(&mm_bob.log_path); + block_on(mm_bob.wait_for_log(22., |log| log.contains(">>>>>>>>> DEX stats "))).unwrap(); + + let mut mm_alice = block_on(MarketMakerIt::start_with_envs( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(alice_priv_key)), + "coins": coins, + "rpc_password": "pass", + "seednodes": vec![format!("{}", mm_bob.ip)], + }), + "pass".to_string(), + None, + &[("MYCOIN_FEE_DISCOUNT", "")], + )) + .unwrap(); + let (_alice_dump_log, _alice_dump_dashboard) = mm_dump(&mm_alice.log_path); + block_on(mm_alice.wait_for_log(22., |log| log.contains(">>>>>>>>> DEX stats "))).unwrap(); + + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_bob, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN", &[], None))); + log!("{:?}", block_on(enable_native(&mm_alice, "MYCOIN1", &[], None))); + let price = MmNumber::from((100, 1620)); + let rc = block_on(mm_bob.rpc(&json!({ + "userpass": mm_bob.userpass, + "method": "setprice", + "base": "MYCOIN", + "rel": "MYCOIN1", + "price": price, + "max": true, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!setprice: {}", rc.1); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "orderbook", + "base": "MYCOIN1", + "rel": "MYCOIN", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!orderbook: {}", rc.1); + log!("{}", rc.1); + thread::sleep(Duration::from_secs(3)); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "max_taker_vol", + "coin": "MYCOIN1", + "trade_with": "MYCOIN", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!max_taker_vol: {}", rc.1); + let vol: MaxTakerVolResponse = serde_json::from_str(&rc.1).unwrap(); + // With 1% fee rate (MYCOIN_FEE_DISCOUNT): max_vol = (balance - tx_fee) / 1.01 + // balance = 50, tx_fee varies, so max_vol β‰ˆ 49.50 + let expected_vol = MmNumber::from((4999999481u64, 101000000u64)); + + let actual_vol = MmNumber::from(vol.result.clone()); + log!("actual vol {}", actual_vol.to_decimal()); + log!("expected vol {}", expected_vol.to_decimal()); + + assert_eq!(expected_vol, actual_vol); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "sell", + "base": "MYCOIN1", + "rel": "MYCOIN", + "price": "16", + "volume": vol.result, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!sell: {}", rc.1); + let sell_res: BuyOrSellRpcResult = serde_json::from_str(&rc.1).unwrap(); + + block_on(mm_bob.wait_for_log(22., |log| log.contains("Entering the maker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + block_on(mm_alice.wait_for_log(22., |log| log.contains("Entering the taker_swap_loop MYCOIN/MYCOIN1"))).unwrap(); + + thread::sleep(Duration::from_secs(3)); + + let rc = block_on(mm_alice.rpc(&json!({ + "userpass": mm_alice.userpass, + "method": "my_swap_status", + "params": { + "uuid": sell_res.result.uuid + } + }))) + .unwrap(); + assert!(rc.0.is_success(), "!my_swap_status: {}", rc.1); + + let status_response: Json = serde_json::from_str(&rc.1).unwrap(); + let events_array = status_response["result"]["events"].as_array().unwrap(); + let first_event_type = events_array[0]["event"]["type"].as_str().unwrap(); + assert_eq!("Started", first_event_type); + block_on(mm_bob.stop()).unwrap(); + block_on(mm_alice.stop()).unwrap(); +} + +// ============================================================================= +// Trade Preimage Tests +// Tests for trade_preimage RPC - fee estimation before swap execution +// ============================================================================= + +#[test] +fn test_maker_trade_preimage() { + let priv_key = random_secp256k1_secret(); + + let (_ctx, mycoin) = utxo_coin_from_privkey("MYCOIN", priv_key); + let my_address = mycoin.my_address().expect("!my_address"); + fill_address(&mycoin, &my_address, 10.into(), 30); + + let (_ctx, mycoin1) = utxo_coin_from_privkey("MYCOIN1", priv_key); + let my_address = mycoin1.my_address().expect("!my_address"); + fill_address(&mycoin1, &my_address, 20.into(), 30); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(2000)]); + let mm = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_dump_log, _dump_dashboard) = mm_dump(&mm.log_path); + + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "trade_preimage", + "params": { + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "setprice", + "price": 1, + "max": true, + }, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!trade_preimage: {}", rc.1); + let base_coin_fee = TradeFeeForTest::new("MYCOIN", "0.00000274", false); // txfee from get_sender_trade_fee + let rel_coin_fee = TradeFeeForTest::new("MYCOIN1", "0.00000992", true); + let volume = MmNumber::from("9.99999726"); // 1.0 - 0.00000274 from calc_max_maker_vol + + let my_coin_total = TotalTradeFeeForTest::new("MYCOIN", "0.00000274", "0.00000274"); + let my_coin1_total = TotalTradeFeeForTest::new("MYCOIN1", "0.00000992", "0"); + + let expected = TradePreimageResult::MakerPreimage(MakerPreimage { + base_coin_fee, + rel_coin_fee, + volume: Some(volume.to_decimal()), + volume_rat: Some(volume.to_ratio()), + volume_fraction: Some(volume.to_fraction()), + total_fees: vec![my_coin_total, my_coin1_total], + }); + + let mut actual: RpcSuccessResponse = serde_json::from_str(&rc.1).unwrap(); + actual.result.sort_total_fees(); + assert_eq!(expected, actual.result); + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "trade_preimage", + "params": { + "base": "MYCOIN1", + "rel": "MYCOIN", + "swap_method": "setprice", + "price": 1, + "max": true, + }, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!trade_preimage: {}", rc.1); + let mut actual: RpcSuccessResponse = serde_json::from_str(&rc.1).unwrap(); + actual.result.sort_total_fees(); + + let base_coin_fee = TradeFeeForTest::new("MYCOIN1", "0.00000548", false); + let rel_coin_fee = TradeFeeForTest::new("MYCOIN", "0.00000496", true); + let volume = MmNumber::from("19.99999452"); + + let my_coin_total = TotalTradeFeeForTest::new("MYCOIN", "0.00000496", "0"); + let my_coin1_total = TotalTradeFeeForTest::new("MYCOIN1", "0.00000548", "0.00000548"); + let expected = TradePreimageResult::MakerPreimage(MakerPreimage { + base_coin_fee, + rel_coin_fee, + volume: Some(volume.to_decimal()), + volume_rat: Some(volume.to_ratio()), + volume_fraction: Some(volume.to_fraction()), + total_fees: vec![my_coin_total, my_coin1_total], + }); + + actual.result.sort_total_fees(); + assert_eq!(expected, actual.result); + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "trade_preimage", + "params": { + "base": "MYCOIN1", + "rel": "MYCOIN", + "swap_method": "setprice", + "price": 1, + "volume": "19.99999109", // actually try max value (balance - txfee = 20.0 - 0.00000823) + }, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!trade_preimage: {}", rc.1); + let mut actual: RpcSuccessResponse = serde_json::from_str(&rc.1).unwrap(); + actual.result.sort_total_fees(); + + let base_coin_fee = TradeFeeForTest::new("MYCOIN1", "0.00000891", false); // txfee updated for calculated max volume (not 616) + let rel_coin_fee = TradeFeeForTest::new("MYCOIN", "0.00000496", true); + + let total_my_coin = TotalTradeFeeForTest::new("MYCOIN", "0.00000496", "0"); + let total_my_coin1 = TotalTradeFeeForTest::new("MYCOIN1", "0.00000891", "0.00000891"); + + let expected = TradePreimageResult::MakerPreimage(MakerPreimage { + base_coin_fee, + rel_coin_fee, + volume: None, + volume_rat: None, + volume_fraction: None, + total_fees: vec![total_my_coin, total_my_coin1], + }); + + actual.result.sort_total_fees(); + assert_eq!(expected, actual.result); +} + +#[test] +fn test_taker_trade_preimage() { + let priv_key = random_secp256k1_secret(); + + let (_ctx, mycoin) = utxo_coin_from_privkey("MYCOIN", priv_key); + let my_address = mycoin.my_address().expect("!my_address"); + fill_address(&mycoin, &my_address, 10.into(), 30); + + let (_ctx, mycoin1) = utxo_coin_from_privkey("MYCOIN1", priv_key); + let my_address = mycoin1.my_address().expect("!my_address"); + fill_address(&mycoin1, &my_address, 20.into(), 30); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(2000)]); + let mm = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_dump_log, _dump_dashboard) = mm_dump(&mm.log_path); + + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + + // `max` field is not supported for `buy/sell` swap methods + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "trade_preimage", + "params": { + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "sell", + "max": true, + "price": 1, + }, + }))) + .unwrap(); + assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); + + let actual: RpcErrorResponse = serde_json::from_str(&rc.1).unwrap(); + assert_eq!(actual.error_type, "InvalidParam", "Unexpected error_type: {}", rc.1); + let expected = trade_preimage_error::InvalidParam { + param: "max".to_owned(), + reason: "'max' cannot be used with 'sell' or 'buy' method".to_owned(), + }; + assert_eq!(actual.error_data, Some(expected)); + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "trade_preimage", + "params": { + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "sell", + "volume": "7.77", + "price": "2", + }, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!trade_preimage: {}", rc.1); + + let mut actual: RpcSuccessResponse = serde_json::from_str(&rc.1).unwrap(); + actual.result.sort_total_fees(); + + let base_coin_fee = TradeFeeForTest::new("MYCOIN", "0.00000274", false); + let rel_coin_fee = TradeFeeForTest::new("MYCOIN1", "0.00000992", true); + // With 2% fee rate: dex_fee = 7.77 * 0.02 = 0.1554 + let taker_fee = TradeFeeForTest::new("MYCOIN", "0.1554", false); + let fee_to_send_taker_fee = TradeFeeForTest::new("MYCOIN", "0.00000245", false); + + // total = taker_fee + base_coin_fee + fee_to_send_taker_fee = 0.1554 + 0.00000274 + 0.00000245 = 0.15540519 + let my_coin_total_fee = TotalTradeFeeForTest::new("MYCOIN", "0.15540519", "0.15540519"); + let my_coin1_total_fee = TotalTradeFeeForTest::new("MYCOIN1", "0.00000992", "0"); + + let expected = TradePreimageResult::TakerPreimage(TakerPreimage { + base_coin_fee, + rel_coin_fee, + taker_fee, + fee_to_send_taker_fee, + total_fees: vec![my_coin_total_fee, my_coin1_total_fee], + }); + assert_eq!(expected, actual.result); + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "trade_preimage", + "params": { + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "buy", + "volume": "7.77", + "price": "2", + }, + }))) + .unwrap(); + assert!(rc.0.is_success(), "!trade_preimage: {}", rc.1); + let mut actual: RpcSuccessResponse = serde_json::from_str(&rc.1).unwrap(); + actual.result.sort_total_fees(); + + let base_coin_fee = TradeFeeForTest::new("MYCOIN", "0.00000496", true); + let rel_coin_fee = TradeFeeForTest::new("MYCOIN1", "0.00000548", false); // fee to send taker payment // With 2% fee rate: buy 7.77 MYCOIN at price 2 = spend 15.54 MYCOIN1, dex_fee = 15.54 * 0.02 = 0.3108 + let taker_fee = TradeFeeForTest::new("MYCOIN1", "0.3108", false); + let fee_to_send_taker_fee = TradeFeeForTest::new("MYCOIN1", "0.0000049", false); + + let my_coin_total_fee = TotalTradeFeeForTest::new("MYCOIN", "0.00000496", "0"); + // total = taker_fee + rel_coin_fee + fee_to_send_taker_fee = 0.3108 + 0.00000548 + 0.0000049 = 0.31081038 + let my_coin1_total_fee = TotalTradeFeeForTest::new("MYCOIN1", "0.31081038", "0.31081038"); + + let expected = TradePreimageResult::TakerPreimage(TakerPreimage { + base_coin_fee, + rel_coin_fee, + taker_fee, + fee_to_send_taker_fee, + total_fees: vec![my_coin_total_fee, my_coin1_total_fee], + }); + assert_eq!(expected, actual.result); +} + +#[test] +fn test_trade_preimage_not_sufficient_balance() { + #[track_caller] + fn expect_not_sufficient_balance( + res: &str, + available: BigDecimal, + required: BigDecimal, + locked_by_swaps: Option, + ) { + let actual: RpcErrorResponse = serde_json::from_str(res).unwrap(); + assert_eq!(actual.error_type, "NotSufficientBalance"); + let expected = trade_preimage_error::NotSufficientBalance { + coin: "MYCOIN".to_owned(), + available, + required, + locked_by_swaps, + }; + assert_eq!(actual.error_data, Some(expected)); + } + + let priv_key = random_secp256k1_secret(); + let fill_balance_functor = |amount: BigDecimal| { + let (_ctx, mycoin) = utxo_coin_from_privkey("MYCOIN", priv_key); + let my_address = mycoin.my_address().expect("!my_address"); + fill_address(&mycoin, &my_address, amount, 30); + }; + + let coins = json!([mycoin_conf(1000), mycoin1_conf(2000)]); + let mm = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_dump_log, _dump_dashboard) = mm_dump(&mm.log_path); + + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + + fill_balance_functor(MmNumber::from("0.00001273").to_decimal()); // volume < txfee + dust = 274 + 1000 + // Try sell the max amount with the zero balance. + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "trade_preimage", + "params": { + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "setprice", + "price": 1, + "max": true, + }, + }))) + .unwrap(); + assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); + let available = MmNumber::from("0.00001273").to_decimal(); + // Required at least 0.00001274 MYCOIN to pay the transaction_fee(0.00000274) and to send a value not less than dust(0.00001) and not less than min_trading_vol (10 * dust). + let required = MmNumber::from("0.00001274").to_decimal(); // TODO: this is not true actually: we can't create orders less that min_trading_vol = 10 * dust + expect_not_sufficient_balance(&rc.1, available, required, Some(MmNumber::from("0").to_decimal())); + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "trade_preimage", + "params": { + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "setprice", + "price": 1, + "volume": 0.1, + }, + }))) + .unwrap(); + assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); + // Required 0.00001 MYCOIN to pay the transaction fee and the specified 0.1 volume. + let available = MmNumber::from("0.00001273").to_decimal(); + let required = MmNumber::from("0.1000024").to_decimal(); + expect_not_sufficient_balance(&rc.1, available, required, None); + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "trade_preimage", + "params": { + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "setprice", + "price": 1, + "max": true, + }, + }))) + .unwrap(); + assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); + // balance(0.00001273) + let available = MmNumber::from("0.00001273").to_decimal(); + // required min_tx_amount(0.00001) + transaction_fee(0.00000274) + let required = MmNumber::from("0.00001274").to_decimal(); + expect_not_sufficient_balance(&rc.1, available, required, Some(MmNumber::from("0").to_decimal())); + + fill_balance_functor(MmNumber::from("7.770085").to_decimal()); + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "trade_preimage", + "params": { + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "sell", + "price": 1, + "volume": 7.77, + }, + }))) + .unwrap(); + assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); + let available = MmNumber::from("7.77009773").to_decimal(); + // `required = volume + fee_to_send_taker_payment + dex_fee + fee_to_send_dex_fee`, + // where `volume = 7.77`, `fee_to_send_taker_payment = 0.00000393, fee_to_send_dex_fee = 0.00000422`. + // With 2% fee rate: dex_fee = 7.77 * 0.02 = 0.1554 + // required = 7.77 + 0.1554 (dex_fee) + (0.00000393 + 0.00000422) = 7.92540815 + let required = MmNumber::from("7.92540815"); + expect_not_sufficient_balance(&rc.1, available, required.to_decimal(), Some(BigDecimal::from(0))); +} + +/// This test ensures that `trade_preimage` will not succeed on input that will fail on `buy/sell/setprice`. +/// https://github.com/KomodoPlatform/atomicDEX-API/issues/902 +#[test] +fn test_trade_preimage_additional_validation() { + let priv_key = random_secp256k1_secret(); + + let (_ctx, mycoin1) = utxo_coin_from_privkey("MYCOIN1", priv_key); + let my_address = mycoin1.my_address().expect("!my_address"); + fill_address(&mycoin1, &my_address, 20.into(), 30); + + let (_ctx, mycoin) = utxo_coin_from_privkey("MYCOIN", priv_key); + let my_address = mycoin.my_address().expect("!my_address"); + fill_address(&mycoin, &my_address, 10.into(), 30); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(2000)]); + + let mm = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_dump_log, _dump_dashboard) = mm_dump(&mm.log_path); + + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + + // Price is too low + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "trade_preimage", + "params": { + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "setprice", + "price": 0, + "volume": 0.1, + }, + }))) + .unwrap(); + assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); + let actual: RpcErrorResponse = serde_json::from_str(&rc.1).unwrap(); + assert_eq!(actual.error_type, "PriceTooLow"); + // currently the minimum price is any value above 0 + let expected = trade_preimage_error::PriceTooLow { + price: BigDecimal::from(0), + threshold: BigDecimal::from(0), + }; + assert_eq!(actual.error_data, Some(expected)); + + // volume 0.00001 is too low, min trading volume 0.0001 + let low_volume = BigDecimal::from(1) / BigDecimal::from(100_000); + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "trade_preimage", + "params": { + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "setprice", + "price": 1, + "volume": low_volume, + }, + }))) + .unwrap(); + assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); + let actual: RpcErrorResponse = serde_json::from_str(&rc.1).unwrap(); + assert_eq!(actual.error_type, "VolumeTooLow"); + // Min MYCOIN trading volume is 0.0001. + let volume_threshold = BigDecimal::from(1) / BigDecimal::from(10_000); + let expected = trade_preimage_error::VolumeTooLow { + coin: "MYCOIN".to_owned(), + volume: low_volume.clone(), + threshold: volume_threshold, + }; + assert_eq!(actual.error_data, Some(expected)); + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "trade_preimage", + "params": { + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "sell", + "price": 1, + "volume": low_volume, + }, + }))) + .unwrap(); + assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); + let actual: RpcErrorResponse = serde_json::from_str(&rc.1).unwrap(); + assert_eq!(actual.error_type, "VolumeTooLow"); + // Min MYCOIN trading volume is 0.0001. + let volume_threshold = BigDecimal::from(1) / BigDecimal::from(10_000); + let expected = trade_preimage_error::VolumeTooLow { + coin: "MYCOIN".to_owned(), + volume: low_volume, + threshold: volume_threshold, + }; + assert_eq!(actual.error_data, Some(expected)); + + // rel volume is too low + // Min MYCOIN trading volume is 0.0001. + let volume = BigDecimal::from(1) / BigDecimal::from(10_000); + let low_price = BigDecimal::from(1) / BigDecimal::from(10); + // Min MYCOIN1 trading volume is 0.0001, but the actual volume is 0.00001 + let low_rel_volume = &volume * &low_price; + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "mmrpc": "2.0", + "method": "trade_preimage", + "params": { + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "sell", + "price": low_price, + "volume": volume, + }, + }))) + .unwrap(); + assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); + let actual: RpcErrorResponse = serde_json::from_str(&rc.1).unwrap(); + assert_eq!(actual.error_type, "VolumeTooLow"); + // Min MYCOIN1 trading volume is 0.0001. + let volume_threshold = BigDecimal::from(1) / BigDecimal::from(10_000); + let expected = trade_preimage_error::VolumeTooLow { + coin: "MYCOIN1".to_owned(), + volume: low_rel_volume, + threshold: volume_threshold, + }; + assert_eq!(actual.error_data, Some(expected)); +} + +#[test] +fn test_trade_preimage_legacy() { + let priv_key = random_secp256k1_secret(); + let (_ctx, mycoin) = utxo_coin_from_privkey("MYCOIN", priv_key); + let my_address = mycoin.my_address().expect("!my_address"); + fill_address(&mycoin, &my_address, 10.into(), 30); + let (_ctx, mycoin1) = utxo_coin_from_privkey("MYCOIN1", priv_key); + let my_address = mycoin1.my_address().expect("!my_address"); + fill_address(&mycoin1, &my_address, 20.into(), 30); + + let coins = json!([mycoin_conf(1000), mycoin1_conf(2000)]); + let mm = MarketMakerIt::start( + json!({ + "gui": "nogui", + "netid": 9000, + "dht": "on", // Enable DHT without delay. + "passphrase": format!("0x{}", hex::encode(priv_key)), + "coins": coins, + "rpc_password": "pass", + "i_am_seed": true, + "is_bootstrap_node": true + }), + "pass".to_string(), + None, + ) + .unwrap(); + let (_dump_log, _dump_dashboard) = mm_dump(&mm.log_path); + + log!("{:?}", block_on(enable_native(&mm, "MYCOIN1", &[], None))); + log!("{:?}", block_on(enable_native(&mm, "MYCOIN", &[], None))); + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "method": "trade_preimage", + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "setprice", + "max": true, + "price": "1", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!trade_preimage: {}", rc.1); + let _: TradePreimageResponse = serde_json::from_str(&rc.1).unwrap(); + + // vvv test a taker method vvv + + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "method": "trade_preimage", + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "sell", + "volume": "7.77", + "price": "2", + }))) + .unwrap(); + assert!(rc.0.is_success(), "!trade_preimage: {}", rc.1); + let _: TradePreimageResponse = serde_json::from_str(&rc.1).unwrap(); + + // vvv test the error response vvv + + // `max` field is not supported for `buy/sell` swap methods + let rc = block_on(mm.rpc(&json!({ + "userpass": mm.userpass, + "method": "trade_preimage", + "base": "MYCOIN", + "rel": "MYCOIN1", + "swap_method": "sell", + "max": true, + "price": "1", + }))) + .unwrap(); + assert!(!rc.0.is_success(), "trade_preimage success, but should fail: {}", rc.1); + assert!(rc + .1 + .contains("Incorrect use of the 'max' parameter: 'max' cannot be used with 'sell' or 'buy' method")); +} diff --git a/mm2src/mm2_main/tests/docker_tests/z_coin_docker_tests.rs b/mm2src/mm2_main/tests/docker_tests/z_coin_docker_tests.rs index 1e9b366fc3..ece4426f38 100644 --- a/mm2src/mm2_main/tests/docker_tests/z_coin_docker_tests.rs +++ b/mm2src/mm2_main/tests/docker_tests/z_coin_docker_tests.rs @@ -2,7 +2,6 @@ use bitcrypto::dhash160; use coins::z_coin::{ z_coin_from_conf_and_params_with_docker, z_send_dex_fee, ZCoin, ZcoinActivationParams, ZcoinRpcMode, }; -use coins::DexFeeBurnDestination; use coins::{ coin_errors::ValidatePaymentError, CoinProtocol, DexFee, PrivKeyBuildPolicy, RefundPaymentArgs, SendPaymentArgs, SpendPaymentArgs, SwapOps, SwapTxTypeWithSecretHash, ValidateFeeArgs, @@ -12,6 +11,7 @@ use lazy_static::lazy_static; use mm2_core::mm_ctx::{MmArc, MmCtxBuilder}; use mm2_number::MmNumber; use mm2_test_helpers::for_tests::zombie_conf_for_docker; +use serde_json::json; use tempfile::TempDir; use tokio::sync::Mutex; @@ -175,6 +175,7 @@ async fn zombie_coin_send_standard_dex_fee() { drop(_lock) } +/// Tests sending a ZCoin DEX fee with Standard fee (burn disabled). #[tokio::test(flavor = "current_thread")] async fn zombie_coin_send_dex_fee() { let _lock = GEN_TX_LOCK_MUTEX_ADDR2.lock().await; @@ -182,16 +183,14 @@ async fn zombie_coin_send_dex_fee() { assert!(coin.is_sapling_state_synced().await); - let dex_fee = DexFee::WithBurn { - fee_amount: "0.0075".into(), - burn_amount: "0.0025".into(), - burn_destination: DexFeeBurnDestination::PreBurnAccount, - }; - let tx = z_send_dex_fee(&coin, dex_fee, &[1; 16]).await.unwrap(); + let tx = z_send_dex_fee(&coin, DexFee::Standard("0.02".into()), &[1; 16]) + .await + .unwrap(); log!("dex fee tx {}", tx.txid()); drop(_lock); } +/// Tests ZCoin DEX fee validation with Standard fees (burn disabled). #[tokio::test(flavor = "current_thread")] async fn zombie_coin_validate_dex_fee() { let _lock = GEN_TX_LOCK_MUTEX.lock().await; @@ -199,20 +198,14 @@ async fn zombie_coin_validate_dex_fee() { assert!(coin.is_sapling_state_synced().await); - let tx = z_send_dex_fee( - &coin, - DexFee::WithBurn { - fee_amount: "0.0075".into(), - burn_amount: "0.0025".into(), - burn_destination: DexFeeBurnDestination::PreBurnAccount, - }, - &[1; 16], - ) - .await - .unwrap(); + // Test standard dex fee (burn is disabled) + let tx = z_send_dex_fee(&coin, DexFee::Standard("0.02".into()), &[1; 16]) + .await + .unwrap(); log!("dex fee tx {}", tx.txid()); let tx = tx.into(); + // Invalid amount should return an error let validate_fee_args = ValidateFeeArgs { fee_tx: &tx, expected_sender: &[], @@ -220,7 +213,6 @@ async fn zombie_coin_validate_dex_fee() { min_block_number: 12000, uuid: &[1; 16], }; - // Invalid amount should return an error let err = coin.validate_fee(validate_fee_args).await.unwrap_err().into_inner(); match err { ValidatePaymentError::WrongPaymentTx(err) => assert!(err.contains("invalid amount")), @@ -228,11 +220,7 @@ async fn zombie_coin_validate_dex_fee() { } // Invalid memo should return an error - let expected_fee = DexFee::WithBurn { - fee_amount: "0.0075".into(), - burn_amount: "0.0025".into(), - burn_destination: DexFeeBurnDestination::PreBurnAccount, - }; + let expected_fee = DexFee::Standard("0.02".into()); let validate_fee_args = ValidateFeeArgs { fee_tx: &tx, @@ -248,7 +236,7 @@ async fn zombie_coin_validate_dex_fee() { _ => panic!("Expected `WrongPaymentTx`: {:?}", err), } - // Success validation + // Success validation with correct amount and memo let validate_fee_args = ValidateFeeArgs { fee_tx: &tx, expected_sender: &[], @@ -258,15 +246,14 @@ async fn zombie_coin_validate_dex_fee() { }; coin.validate_fee(validate_fee_args).await.unwrap(); - // Test old standard dex fee with no burn output - // TODO: disable when the upgrade transition period ends + // Test with different fee amount let tx_2 = z_send_dex_fee(&coin, DexFee::Standard("0.00879999".into()), &[1; 16]) .await .unwrap(); log!("dex fee tx {}", tx_2.txid()); let tx_2 = tx_2.into(); - // Success validation + // Wrong expected amount should fail let validate_fee_args = ValidateFeeArgs { fee_tx: &tx_2, expected_sender: &[], @@ -280,7 +267,7 @@ async fn zombie_coin_validate_dex_fee() { _ => panic!("Expected `WrongPaymentTx`: {:?}", err), } - // Success validation + // Correct expected amount should succeed let expected_std_fee = DexFee::Standard("0.00879999".into()); let validate_fee_args = ValidateFeeArgs { fee_tx: &tx_2, diff --git a/mm2src/mm2_main/tests/docker_tests_main.rs b/mm2src/mm2_main/tests/docker_tests_main.rs index c85ffe4c05..9899f1da46 100644 --- a/mm2src/mm2_main/tests/docker_tests_main.rs +++ b/mm2src/mm2_main/tests/docker_tests_main.rs @@ -7,45 +7,28 @@ #[cfg(test)] #[macro_use] extern crate common; -#[cfg(test)] +#[cfg(all(test, feature = "docker-tests-qrc20"))] #[macro_use] extern crate gstuff; #[cfg(test)] #[macro_use] extern crate lazy_static; #[cfg(test)] -#[macro_use] -extern crate serde_json; -#[cfg(test)] extern crate ser_error_derive; #[cfg(test)] extern crate test; -use common::custom_futures::timeout::FutureTimerExt; -use std::env; -use std::io::{BufRead, BufReader}; -use std::path::PathBuf; -use std::process::Command; -use std::time::Duration; -use test::{test_main, StaticBenchFn, StaticTestFn, TestDescAndFn}; +use test::TestDescAndFn; mod docker_tests; + +// Sia tests are gated on docker-tests-sia feature to prevent them from running in other docker test jobs +#[cfg(feature = "docker-tests-sia")] mod sia_tests; -use docker_tests::docker_tests_common::*; -use docker_tests::qrc20_tests::{qtum_docker_node, QtumDockerOps, QTUM_REGTEST_DOCKER_IMAGE_WITH_TAG}; -use sia_tests::utils::wait_for_dsia_node_ready; #[allow(dead_code)] mod integration_tests_common; -const ENV_VAR_NO_UTXO_DOCKER: &str = "_KDF_NO_UTXO_DOCKER"; -const ENV_VAR_NO_QTUM_DOCKER: &str = "_KDF_NO_QTUM_DOCKER"; -const ENV_VAR_NO_SLP_DOCKER: &str = "_KDF_NO_SLP_DOCKER"; -const ENV_VAR_NO_ETH_DOCKER: &str = "_KDF_NO_ETH_DOCKER"; -const ENV_VAR_NO_COSMOS_DOCKER: &str = "_KDF_NO_COSMOS_DOCKER"; -const ENV_VAR_NO_ZOMBIE_DOCKER: &str = "_KDF_NO_ZOMBIE_DOCKER"; -const ENV_VAR_NO_SIA_DOCKER: &str = "_KDF_NO_SIA_DOCKER"; - // AP: custom test runner is intended to initialize the required environment (e.g. coin daemons in the docker containers) // and then gracefully clear it by dropping the RAII docker container handlers // I've tried to use static for such singleton initialization but it turned out that despite @@ -55,231 +38,5 @@ const ENV_VAR_NO_SIA_DOCKER: &str = "_KDF_NO_SIA_DOCKER"; // Windows - https://github.com/KomodoPlatform/komodo/blob/master/zcutil/fetch-params.bat // Linux and MacOS - https://github.com/KomodoPlatform/komodo/blob/master/zcutil/fetch-params.sh pub fn docker_tests_runner(tests: &[&TestDescAndFn]) { - // pretty_env_logger::try_init(); - let mut containers = vec![]; - // skip Docker containers initialization if we are intended to run test_mm_start only - if env::var("_MM2_TEST_CONF").is_err() { - let mut images = vec![]; - - let disable_utxo: bool = env::var(ENV_VAR_NO_UTXO_DOCKER).is_ok(); - let disable_slp: bool = env::var(ENV_VAR_NO_SLP_DOCKER).is_ok(); - let disable_qtum: bool = env::var(ENV_VAR_NO_QTUM_DOCKER).is_ok(); - let disable_eth: bool = env::var(ENV_VAR_NO_ETH_DOCKER).is_ok(); - let disable_cosmos: bool = env::var(ENV_VAR_NO_COSMOS_DOCKER).is_ok(); - let disable_zombie: bool = env::var(ENV_VAR_NO_ZOMBIE_DOCKER).is_ok(); - let disable_sia: bool = env::var(ENV_VAR_NO_SIA_DOCKER).is_ok(); - - if !disable_utxo || !disable_slp { - images.push(UTXO_ASSET_DOCKER_IMAGE_WITH_TAG) - } - if !disable_qtum { - images.push(QTUM_REGTEST_DOCKER_IMAGE_WITH_TAG); - } - if !disable_eth { - images.push(GETH_DOCKER_IMAGE_WITH_TAG); - } - if !disable_cosmos { - images.push(NUCLEUS_IMAGE); - images.push(ATOM_IMAGE_WITH_TAG); - images.push(IBC_RELAYER_IMAGE_WITH_TAG); - } - if !disable_zombie { - images.push(ZOMBIE_ASSET_DOCKER_IMAGE_WITH_TAG); - } - - if !disable_sia { - images.push(SIA_DOCKER_IMAGE_WITH_TAG); - } - - for image in images { - pull_docker_image(image); - remove_docker_containers(image); - } - - let (nucleus_node, atom_node, ibc_relayer_node) = if !disable_cosmos { - let runtime_dir = prepare_runtime_dir().unwrap(); - let nucleus_node = nucleus_node(runtime_dir.clone()); - let atom_node = atom_node(runtime_dir.clone()); - let ibc_relayer_node = ibc_relayer_node(runtime_dir); - (Some(nucleus_node), Some(atom_node), Some(ibc_relayer_node)) - } else { - (None, None, None) - }; - let (utxo_node, utxo_node1) = if !disable_utxo { - let utxo_node = utxo_asset_docker_node("MYCOIN", 8000); - let utxo_node1 = utxo_asset_docker_node("MYCOIN1", 8001); - (Some(utxo_node), Some(utxo_node1)) - } else { - (None, None) - }; - let qtum_node = if !disable_qtum { - let qtum_node = qtum_docker_node(9000); - Some(qtum_node) - } else { - None - }; - let for_slp_node = if !disable_slp { - let for_slp_node = utxo_asset_docker_node("FORSLP", 10000); - Some(for_slp_node) - } else { - None - }; - let geth_node = if !disable_eth { - let geth_node = geth_docker_node("ETH", 8545); - Some(geth_node) - } else { - None - }; - let zombie_node = if !disable_zombie { - let zombie_node = zombie_asset_docker_node(7090); - Some(zombie_node) - } else { - None - }; - - let sia_node = if !disable_sia { - let sia_node = sia_docker_node("SIA", 9980); - Some(sia_node) - } else { - None - }; - - if let (Some(utxo_node), Some(utxo_node1)) = (utxo_node, utxo_node1) { - let utxo_ops = UtxoAssetDockerOps::from_ticker("MYCOIN"); - let utxo_ops1 = UtxoAssetDockerOps::from_ticker("MYCOIN1"); - utxo_ops.wait_ready(4); - utxo_ops1.wait_ready(4); - containers.push(utxo_node); - containers.push(utxo_node1); - } - if let Some(qtum_node) = qtum_node { - let qtum_ops = QtumDockerOps::new(); - qtum_ops.wait_ready(2); - qtum_ops.initialize_contracts(); - containers.push(qtum_node); - } - if let Some(for_slp_node) = for_slp_node { - let for_slp_ops = BchDockerOps::from_ticker("FORSLP"); - for_slp_ops.wait_ready(4); - for_slp_ops.initialize_slp(); - containers.push(for_slp_node); - } - if let Some(geth_node) = geth_node { - wait_for_geth_node_ready(); - init_geth_node(); - containers.push(geth_node); - } - if let Some(zombie_node) = zombie_node { - let zombie_ops = ZCoinAssetDockerOps::new(); - zombie_ops.wait_ready(4); - containers.push(zombie_node); - } - if let (Some(nucleus_node), Some(atom_node), Some(ibc_relayer_node)) = - (nucleus_node, atom_node, ibc_relayer_node) - { - prepare_ibc_channels(ibc_relayer_node.container.id()); - thread::sleep(Duration::from_secs(10)); - wait_until_relayer_container_is_ready(ibc_relayer_node.container.id()); - containers.push(nucleus_node); - containers.push(atom_node); - containers.push(ibc_relayer_node); - } - if let Some(sia_node) = sia_node { - block_on(wait_for_dsia_node_ready()); - containers.push(sia_node); - } - } - // detect if docker is installed - // skip the tests that use docker if not installed - let owned_tests: Vec<_> = tests - .iter() - .map(|t| match t.testfn { - StaticTestFn(f) => TestDescAndFn { - testfn: StaticTestFn(f), - desc: t.desc.clone(), - }, - StaticBenchFn(f) => TestDescAndFn { - testfn: StaticBenchFn(f), - desc: t.desc.clone(), - }, - _ => panic!("non-static tests passed to lp_coins test runner"), - }) - .collect(); - let args: Vec = env::args().collect(); - test_main(&args, owned_tests, None); -} - -fn wait_for_geth_node_ready() { - let mut attempts = 0; - loop { - if attempts >= 5 { - panic!("Failed to connect to Geth node after several attempts."); - } - match block_on(GETH_WEB3.eth().block_number().timeout(Duration::from_secs(6))) { - Ok(Ok(block_number)) => { - log!("Geth node is ready, latest block number: {:?}", block_number); - break; - }, - Ok(Err(e)) => { - log!("Failed to connect to Geth node: {:?}, retrying...", e); - }, - Err(_) => { - log!("Connection to Geth node timed out, retrying..."); - }, - } - attempts += 1; - thread::sleep(Duration::from_secs(1)); - } -} - -fn pull_docker_image(name: &str) { - Command::new("docker") - .arg("pull") - .arg(name) - .status() - .expect("Failed to execute docker command"); -} - -fn remove_docker_containers(name: &str) { - let stdout = Command::new("docker") - .arg("ps") - .arg("-f") - .arg(format!("ancestor={name}")) - .arg("-q") - .output() - .expect("Failed to execute docker command"); - - let reader = BufReader::new(stdout.stdout.as_slice()); - let ids: Vec<_> = reader.lines().map(|line| line.unwrap()).collect(); - if !ids.is_empty() { - Command::new("docker") - .arg("rm") - .arg("-f") - .args(ids) - .status() - .expect("Failed to execute docker command"); - } -} - -fn prepare_runtime_dir() -> std::io::Result { - let project_root = { - let mut current_dir = std::env::current_dir().unwrap(); - current_dir.pop(); - current_dir.pop(); - current_dir - }; - - let containers_state_dir = project_root.join(".docker/container-state"); - assert!(containers_state_dir.exists()); - let containers_runtime_dir = project_root.join(".docker/container-runtime"); - - // Remove runtime directory if it exists to copy containers files to a clean directory - if containers_runtime_dir.exists() { - std::fs::remove_dir_all(&containers_runtime_dir).unwrap(); - } - - // Copy container files to runtime directory - mm2_io::fs::copy_dir_all(&containers_state_dir, &containers_runtime_dir).unwrap(); - - Ok(containers_runtime_dir) + docker_tests::runner::docker_tests_runner_impl(tests) } diff --git a/mm2src/mm2_main/tests/mm2_tests/mm2_tests_inner.rs b/mm2src/mm2_main/tests/mm2_tests/mm2_tests_inner.rs index 4f302bfa6b..7eb0d5fe4c 100644 --- a/mm2src/mm2_main/tests/mm2_tests/mm2_tests_inner.rs +++ b/mm2src/mm2_main/tests/mm2_tests/mm2_tests_inner.rs @@ -2356,7 +2356,9 @@ fn test_metrics_method() { .expect(r#"Couldn't find a metric with key = "traffic.out" and label: coin = "RICK" in received json"#); } +// TODO: Re-enable once Electrum servers are dockerized: https://github.com/KomodoPlatform/komodo-defi-framework/issues/2708 #[test] +#[ignore] #[cfg(not(target_arch = "wasm32"))] fn test_electrum_tx_history() { fn get_tx_history_request_count(mm: &MarketMakerIt) -> u64 { @@ -2777,7 +2779,9 @@ fn test_convert_eth_address() { assert!(rc.1.contains("Address must be prefixed with 0x")); } +// TODO: Re-enable once Electrum servers are dockerized: https://github.com/KomodoPlatform/komodo-defi-framework/issues/2708 #[test] +#[ignore] #[cfg(not(target_arch = "wasm32"))] fn test_add_delegation_qtum() { let coins = json!([{ @@ -2927,7 +2931,9 @@ fn test_remove_delegation_qtum() { ); } +// TODO: Re-enable once Electrum servers are dockerized: https://github.com/KomodoPlatform/komodo-defi-framework/issues/2708 #[test] +#[ignore] #[cfg(not(target_arch = "wasm32"))] fn test_query_delegations_info_qtum() { let coins = json!([{ @@ -3410,7 +3416,9 @@ fn qrc20_activate_electrum() { assert_eq!(electrum_json["balance"].as_str(), Some("139")); } +// TODO: Re-enable once Electrum servers are dockerized: https://github.com/KomodoPlatform/komodo-defi-framework/issues/2708 #[test] +#[ignore] #[cfg(not(target_arch = "wasm32"))] fn test_qrc20_withdraw() { // corresponding private key: [3, 98, 177, 3, 108, 39, 234, 144, 131, 178, 103, 103, 127, 80, 230, 166, 53, 68, 147, 215, 42, 216, 144, 72, 172, 110, 180, 13, 123, 179, 10, 49] @@ -4112,7 +4120,21 @@ fn test_update_maker_order() { let max_base_vol = BigDecimal::from_str(update_maker_order_json["result"]["max_base_vol"].as_str().unwrap()).unwrap(); assert_eq!(update_maker_order_json["result"]["price"], Json::from("2")); - assert_eq!(max_base_vol, max_volume); + // Approximate comparison: fee/balance can change slightly between the my_balance/trade_preimage + // calls above and the update_maker_order call + let diff = if max_base_vol > max_volume { + &max_base_vol - &max_volume + } else { + &max_volume - &max_base_vol + }; + let tolerance = BigDecimal::from_str("0.0001").unwrap(); + assert!( + diff < tolerance, + "max_base_vol {} differs from expected {} by more than {}", + max_base_vol, + max_volume, + tolerance + ); block_on(mm_bob.stop()).unwrap(); } @@ -5592,8 +5614,12 @@ fn test_sign_verify_message_eth_with_derivation_path() { "0x36b91a54f905f2dd88ecfd7f4a539710c699eaab2b425ba79ad959c29ec26492011674981da72d68ac0ab72bb35661a13c42bce314ecdfff0e44174f82a7ee2501"; assert_eq!(expected_signature, response.signature); - let address0 = match result.wallet_balance { - EnableCoinBalanceMap::HD(bal) => bal.accounts[0].addresses[0].address.clone(), + // Addresses were used before, so they are included in the activation result. + let (address0, address1) = match result.wallet_balance { + EnableCoinBalanceMap::HD(bal) => ( + bal.accounts[0].addresses[0].address.clone(), + bal.accounts[0].addresses[1].address.clone(), + ), EnableCoinBalanceMap::Iguana(_) => panic!("Expected HD"), }; let response = block_on(verify_message(&mm_bob, "ETH", expected_signature, &address0)); @@ -5603,8 +5629,6 @@ fn test_sign_verify_message_eth_with_derivation_path() { assert!(response.is_valid); // Test address 1. - let get_new_address = block_on(get_new_address(&mm_bob, "ETH", 0, Some(Bip44Chain::External))); - assert!(get_new_address.new_address.balance.contains_key("ETH")); let response = block_on(sign_message( &mm_bob, "ETH", @@ -5621,12 +5645,7 @@ fn test_sign_verify_message_eth_with_derivation_path() { "0xc8aa1d54c311e38edc815308dc67018aecbd6d4008a88b9af7aba9c98997b7b56f9e6eab64b3c496c6fff1762ae0eba8228370b369d505dd9087cded0a4d947a01"; assert_eq!(expected_signature, response.signature); - let response = block_on(verify_message( - &mm_bob, - "ETH", - expected_signature, - &get_new_address.new_address.address, - )); + let response = block_on(verify_message(&mm_bob, "ETH", expected_signature, &address1)); let response: RpcV2Response = json::from_value(response).unwrap(); let response = response.result; @@ -6202,7 +6221,7 @@ fn test_enable_utxo_with_enable_hd() { block_on(get_new_address(&mm_hd_0, "BTC-segwit", 77, Some(Bip44Chain::External))); } let account_balance: HDAccountBalanceResponse = - block_on(account_balance(&mm_hd_0, "BTC-segwit", 77, Bip44Chain::External)); + block_on(account_balance(&mm_hd_0, "BTC-segwit", 77, Bip44Chain::External, None)); assert_eq!( account_balance.addresses[7].address, "bc1q0dxnd7afj997a40j86a8a6dq3xs3dwm7rkzams" @@ -6686,8 +6705,8 @@ mod trezor_tests { use coins::rpc_command::get_new_address::{GetNewAddressParams, GetNewAddressRpcOps}; use coins::rpc_command::init_create_account::for_tests::test_create_new_account_init_loop; use coins::utxo::{utxo_standard::UtxoStandardCoin, UtxoActivationParams}; - use coins::EthGasLimitOption; use coins::{lp_coinfind, CoinProtocol, MmCoinEnum, PrivKeyBuildPolicy}; + use coins::{EthGasLimitOption, PrivKeyActivationPolicy}; use coins_activation::platform_for_tests::init_platform_coin_with_tokens_loop; use coins_activation::{for_tests::init_standalone_coin_loop, InitStandaloneCoinReq}; use common::executor::Timer; @@ -6974,7 +6993,7 @@ mod trezor_tests { tbtc_electrums(), None, 80, - Some("Trezor"), + Some(json!(PrivKeyActivationPolicy::Trezor)), )); log!("enable UTXO bob {:?}", utxo_bob); @@ -7015,7 +7034,7 @@ mod trezor_tests { tbtc_electrums(), None, 80, - Some("Trezor"), + Some(json!(PrivKeyActivationPolicy::Trezor)), )); log!("enable UTXO bob {:?}", utxo_bob); diff --git a/mm2src/mm2_main/tests/mm2_tests/mod.rs b/mm2src/mm2_main/tests/mm2_tests/mod.rs index 9e60c95457..ad454d1101 100644 --- a/mm2src/mm2_main/tests/mm2_tests/mod.rs +++ b/mm2src/mm2_main/tests/mm2_tests/mod.rs @@ -5,6 +5,9 @@ mod lightning_tests; mod lp_bot_tests; mod mm2_tests_inner; mod orderbook_sync_tests; +#[cfg(feature = "tron-network-tests")] +mod tron_tests; +mod wallet_connect_tests; mod z_coin_tests; mod solana_tests; diff --git a/mm2src/mm2_main/tests/mm2_tests/orderbook_sync_tests.rs b/mm2src/mm2_main/tests/mm2_tests/orderbook_sync_tests.rs index 86bbfbca53..3939133238 100644 --- a/mm2src/mm2_main/tests/mm2_tests/orderbook_sync_tests.rs +++ b/mm2src/mm2_main/tests/mm2_tests/orderbook_sync_tests.rs @@ -1321,7 +1321,9 @@ fn setprice_min_volume_should_be_displayed_in_orderbook() { assert_eq!(min_volume, "1", "Alice MORTY/RICK ask must display correct min_volume"); } +// TODO: Re-enable or rewrite as part of orderbook sync improvements: https://github.com/KomodoPlatform/komodo-defi-framework/pull/2626 #[test] +#[ignore] fn test_order_cancellation_received_before_creation() { let coins = json!([rick_conf(), morty_conf()]); diff --git a/mm2src/mm2_main/tests/mm2_tests/tron_tests.rs b/mm2src/mm2_main/tests/mm2_tests/tron_tests.rs new file mode 100644 index 0000000000..5230e946df --- /dev/null +++ b/mm2src/mm2_main/tests/mm2_tests/tron_tests.rs @@ -0,0 +1,1180 @@ +//! TRON integration tests +//! +//! Run with: cargo test --test mm2_tests_main --features tron-network-tests tron_ + +use coins::eth::tron::{TronAddress, TronApiClient, TronHttpClient, TronHttpNode}; +use coins::TxFeeDetails; +use common::block_on; +use mm2_number::bigdecimal::BigDecimal; +use mm2_test_helpers::for_tests::{ + account_balance, enable_erc20_token_v2, enable_trx_with_tokens, get_new_address, my_balance, send_raw_transaction, + task_enable_trx, task_enable_trx_with_tokens, trc20_usdt_nile_conf, trx_conf, withdraw_v1, MarketMakerIt, + Mm2TestConf, Mm2TestConfForSwap, TRON_NILE_NODES, TRON_NILE_TRC20_USDT_CONTRACT, TRON_NILE_TRC20_USDT_TICKER, + TRON_WITHDRAW_TEST_PASSPHRASE, +}; +use mm2_test_helpers::structs::{ + Bip44Chain, EnableCoinBalanceMap, EthWithTokensActivationResult, HDAccountAddressId, TransactionDetails, +}; +use std::str::FromStr; + +/// Test mnemonic for used-but-zero-balance scenario. +/// Index 0: TSqB9tqfaQ1DYSdMCbVSLPzQsaNVjeu9hq (funded ~1777.8 TRX) +/// Index 2: TPoJwueR4xfZCXuQTYqem4edQgoM3uV78n (0 balance but has tx history) +const TRON_USED_ZERO_BALANCE_PASSPHRASE: &str = + "top wonder island doctor gesture velvet local media begin impose soccer radar"; + +/// BOB_HD_PASSPHRASE address at index 10 - funded with TRC20 USDT only (no TRX). +/// Beyond the last TRX-funded address (index 7), used to verify TRC20-only detection +/// during HD wallet gap scanning. +const BOB_HD_TRC20_ONLY_ADDRESS_INDEX_10: &str = "THng6CmEwpJqu5GJN6TabY2sRicKqJPS25"; + +/// Test TRX + TRC20 activation works via enable_eth_with_tokens (immediate mode). +/// Also validates TRC20 token balance propagation in HD wallet structure. +#[test] +fn test_trx_activation_immediate() { + // Validate TRC20 contract address constant (from_base58 checks the 0x41 prefix that encodes to 'T') + TronAddress::from_base58(TRON_NILE_TRC20_USDT_CONTRACT).expect("Invalid TRC20 Base58 contract address constant"); + + let coins = serde_json::json!([trx_conf(), trc20_usdt_nile_conf()]); + let conf = Mm2TestConf::seednode_with_hd_account(Mm2TestConfForSwap::BOB_HD_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + let result = block_on(enable_trx_with_tokens( + &mm, + TRON_NILE_NODES, + &[TRON_NILE_TRC20_USDT_TICKER], + )); + + assert!(result.get("result").is_some(), "Expected result field in response"); + let activation: EthWithTokensActivationResult = + serde_json::from_value(result["result"].clone()).expect("Failed to parse activation result"); + + let hd = match activation { + EthWithTokensActivationResult::HD(hd) => hd, + EthWithTokensActivationResult::Iguana(_) => { + panic!("Expected HD activation result for TRX+TRC20 platform activation") + }, + }; + + assert!(hd.current_block > 0, "current_block should be greater than 0"); + assert_eq!(hd.ticker, "TRX", "Platform ticker should be TRX"); + + // Validate TRC20 token balance is present at specific address (like ETH tests) + let balance = match hd.wallet_balance { + EnableCoinBalanceMap::HD(hd_bal) => hd_bal, + _ => panic!("Expected EnableCoinBalanceMap::HD"), + }; + let account0 = balance.accounts.first().expect("Expected first HD account entry"); + assert!( + account0.addresses[0].balance.contains_key(TRON_NILE_TRC20_USDT_TICKER), + "Expected TRC20 {} balance entry for address index 0", + TRON_NILE_TRC20_USDT_TICKER + ); + + block_on(mm.stop()).unwrap(); +} + +/// Test TRX + TRC20 activation works via task::enable_eth::init (task-based mode). +/// Also validates TRC20 token balance propagation in HD wallet structure. +#[test] +fn test_trx_activation_task_based() { + // Validate TRC20 contract address constant (from_base58 checks the 0x41 prefix that encodes to 'T') + TronAddress::from_base58(TRON_NILE_TRC20_USDT_CONTRACT).expect("Invalid TRC20 Base58 contract address constant"); + + let coins = serde_json::json!([trx_conf(), trc20_usdt_nile_conf()]); + let conf = Mm2TestConf::seednode_with_hd_account(Mm2TestConfForSwap::BOB_HD_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + let result = block_on(task_enable_trx_with_tokens( + &mm, + TRON_NILE_NODES, + &[TRON_NILE_TRC20_USDT_TICKER], + 90, + None, + )) + .expect("TRX+TRC20 task-based activation should succeed"); + + let hd = match result { + EthWithTokensActivationResult::HD(hd) => hd, + EthWithTokensActivationResult::Iguana(_) => { + panic!("Expected HD activation result for TRX+TRC20 platform activation (task-based)") + }, + }; + + assert!(hd.current_block > 0, "current_block should be greater than 0"); + assert_eq!(hd.ticker, "TRX", "Ticker should be TRX"); + + // Validate TRC20 token balance is present at specific address (like ETH tests) + let balance = match hd.wallet_balance { + EnableCoinBalanceMap::HD(hd_bal) => hd_bal, + _ => panic!("Expected EnableCoinBalanceMap::HD"), + }; + let account0 = balance.accounts.first().expect("Expected first HD account entry"); + assert!( + account0.addresses[0].balance.contains_key(TRON_NILE_TRC20_USDT_TICKER), + "Expected TRC20 {} balance entry for address index 0", + TRON_NILE_TRC20_USDT_TICKER + ); + + block_on(mm.stop()).unwrap(); +} + +/// Test node failover: dead node first, good node second = success +#[test] +fn test_trx_activation_node_failover() { + let coins = serde_json::json!([trx_conf()]); + let conf = Mm2TestConf::seednode(Mm2TestConfForSwap::BOB_HD_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + let nodes = ["http://127.0.0.1:1", TRON_NILE_NODES[0]]; + let result = + block_on(task_enable_trx(&mm, &nodes, 60, None)).expect("Expected TRX activation to succeed via node failover"); + + match result { + EthWithTokensActivationResult::Iguana(r) => { + assert!(r.current_block > 0); + assert!(!r.eth_addresses_infos.is_empty(), "Expected at least one address"); + for addr in r.eth_addresses_infos.keys() { + TronAddress::from_base58(addr).expect("Invalid base58check TRON address"); + } + }, + EthWithTokensActivationResult::HD(r) => { + assert!(r.current_block > 0); + assert_eq!(r.ticker, "TRX"); + }, + } + + block_on(mm.stop()).unwrap(); +} + +/// Test HD wallet activation with specific derivation path +#[test] +fn test_trx_hd_activation_with_path() { + let coins = serde_json::json!([trx_conf()]); + let conf = Mm2TestConf::seednode_with_hd_account(Mm2TestConfForSwap::BOB_HD_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + let path_to_address = HDAccountAddressId { + account_id: 0, + chain: Bip44Chain::External, + address_id: 0, + }; + + let result = block_on(task_enable_trx(&mm, TRON_NILE_NODES, 60, Some(path_to_address))) + .expect("Expected TRX HD activation to succeed"); + + let hd = match result { + EthWithTokensActivationResult::HD(hd) => hd, + EthWithTokensActivationResult::Iguana(_) => panic!("Expected HD activation result"), + }; + + let balance = match hd.wallet_balance { + EnableCoinBalanceMap::HD(hd_bal) => hd_bal, + EnableCoinBalanceMap::Iguana(_) => panic!("Expected EnableCoinBalanceMap::HD"), + }; + + let account0 = balance + .accounts + .first() + .expect("Expected account 0 in HD wallet balance"); + let addr0 = &account0.addresses[0].address; + + TronAddress::from_base58(addr0).expect("Invalid base58check TRON address"); + + block_on(mm.stop()).unwrap(); +} + +/// Test get_new_address and account_balance RPCs with TRC20 token propagation. +/// Validates that TRC20 token balances are included in new address responses. +#[test] +fn test_trx_get_new_address_rpc_hd() { + let coins = serde_json::json!([trx_conf(), trc20_usdt_nile_conf()]); + let conf = Mm2TestConf::seednode_with_hd_account(Mm2TestConfForSwap::BOB_HD_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + // Activate TRX with TRC20 token + let _activation = block_on(task_enable_trx_with_tokens( + &mm, + TRON_NILE_NODES, + &[TRON_NILE_TRC20_USDT_TICKER], + 90, + Some(HDAccountAddressId { + account_id: 0, + chain: Bip44Chain::External, + address_id: 0, + }), + )) + .expect("Expected TRX+TRC20 HD activation to succeed"); + + // Test get_new_address for TRX + let addr1 = block_on(get_new_address(&mm, "TRX", 0, Some(Bip44Chain::External))); + TronAddress::from_base58(&addr1.new_address.address) + .expect("Invalid base58check TRON address returned by get_new_address"); + + match addr1.new_address.chain { + Bip44Chain::External => (), + Bip44Chain::Internal => panic!("Expected External chain for get_new_address(TRX)"), + }; + + assert!( + addr1.new_address.derivation_path.starts_with("m/44'/195'/0'/0/"), + "Unexpected TRX derivation_path: {}", + addr1.new_address.derivation_path + ); + assert!( + addr1.new_address.balance.contains_key("TRX"), + "Expected TRX balance entry for get_new_address response" + ); + // TRC20 token balance should be included in the new address balance map + assert!( + addr1.new_address.balance.contains_key(TRON_NILE_TRC20_USDT_TICKER), + "Expected TRC20 {} balance entry for get_new_address response", + TRON_NILE_TRC20_USDT_TICKER + ); + + // Test account_balance includes the new address. + // During HD activation the scanner walks addresses up to the gap limit (default 20) checking + // both TRX account existence and TRC20 balances via is_address_used(). This means the wallet + // can have 20+ known addresses after activation. account_balance defaults to page size 10, + // so we pass limit=50 to ensure the newly generated address is included in the response. + let bal = block_on(account_balance(&mm, "TRX", 0, Bip44Chain::External, Some(50))); + let found = bal.addresses.iter().any(|a| a.address == addr1.new_address.address); + assert!( + found, + "Expected get_new_address(TRX) address to be present in account_balance addresses list" + ); + + // Verify TRC20 token balance is present in account_balance response + let addr_with_token = bal.addresses.iter().find(|a| a.address == addr1.new_address.address); + assert!( + addr_with_token.is_some_and(|a| a.balance.contains_key(TRON_NILE_TRC20_USDT_TICKER)), + "Expected TRC20 {} balance in account_balance address entry", + TRON_NILE_TRC20_USDT_TICKER + ); + + let addr2 = block_on(get_new_address(&mm, "TRX", 0, Some(Bip44Chain::External))); + assert_ne!(addr1.new_address.address, addr2.new_address.address); + + block_on(mm.stop()).unwrap(); +} + +/// Test HD balance structure with funded addresses (BOB_HD_PASSPHRASE) +/// Funding: index 0 (~1967 TRX), index 1 (20 TRX), index 7 (5 TRX) +/// Also validates TRC20 token balance structure propagation across all addresses. +#[test] +fn test_trx_hd_balance_structure_assertions_and_funded_amounts() { + let coins = serde_json::json!([trx_conf(), trc20_usdt_nile_conf()]); + let conf = Mm2TestConf::seednode_with_hd_account(Mm2TestConfForSwap::BOB_HD_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + let result = block_on(task_enable_trx_with_tokens( + &mm, + TRON_NILE_NODES, + &[TRON_NILE_TRC20_USDT_TICKER], + 90, + Some(HDAccountAddressId { + account_id: 0, + chain: Bip44Chain::External, + address_id: 7, + }), + )) + .expect("Expected TRX+TRC20 HD activation to succeed"); + + let hd = match result { + EthWithTokensActivationResult::HD(hd) => hd, + _ => panic!("Expected HD activation result"), + }; + assert_eq!(hd.ticker, "TRX"); + assert!(hd.current_block > 0); + + let balance = match hd.wallet_balance { + EnableCoinBalanceMap::HD(hd_bal) => hd_bal, + _ => panic!("Expected EnableCoinBalanceMap::HD"), + }; + + let account0 = balance.accounts.first().expect("Expected first HD account entry"); + assert_eq!(account0.account_index, 0, "Expected account_index=0"); + assert!( + account0.addresses.len() >= 8, + "Expected at least 8 addresses (0..=7), got {}", + account0.addresses.len() + ); + + assert_eq!(account0.addresses[0].address, "TYiKfTcdB3q9ZMRkoDM9qQ5CasvdBaoSdP"); + assert_eq!(account0.addresses[1].address, "TKzvw3u4SXzxfu69rVvNpjs5NiE5ZE4NJi"); + assert_eq!(account0.addresses[7].address, "TBic1drXQNM1BiBevg751GsZtv59GWb6ZK"); + + for idx in [0usize, 1usize, 7usize] { + TronAddress::from_base58(&account0.addresses[idx].address).expect("Invalid TRON Base58 address"); + assert!( + account0.addresses[idx].balance.contains_key("TRX"), + "Expected TRX balance entry for address index {}", + idx + ); + // TRC20 token balance should be present for each address + assert!( + account0.addresses[idx] + .balance + .contains_key(TRON_NILE_TRC20_USDT_TICKER), + "Expected TRC20 {} balance entry for address index {}", + TRON_NILE_TRC20_USDT_TICKER, + idx + ); + } + + let spendable0 = &account0.addresses[0].balance.get("TRX").unwrap().spendable; + let spendable1 = &account0.addresses[1].balance.get("TRX").unwrap().spendable; + let spendable7 = &account0.addresses[7].balance.get("TRX").unwrap().spendable; + + assert!( + *spendable0 > 1900.into(), + "Expected index 0 to have a large funded TRX balance, got {:?}", + spendable0 + ); + assert!( + *spendable1 > 15.into(), + "Expected index 1 to have ~20 TRX funded balance, got {:?}", + spendable1 + ); + assert!( + *spendable7 > 3.into(), + "Expected index 7 to have ~5 TRX funded balance, got {:?}", + spendable7 + ); + + block_on(mm.stop()).unwrap(); +} + +/// Test HD with account_id = 77 (mirrors ETH test pattern) +/// Also validates TRC20 token balance propagation and derivation paths. +#[test] +fn test_trx_hd_multiple_account_ids_account_77() { + let coins = serde_json::json!([trx_conf(), trc20_usdt_nile_conf()]); + let conf = Mm2TestConf::seednode_with_hd_account(Mm2TestConfForSwap::BOB_HD_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + let result = block_on(task_enable_trx_with_tokens( + &mm, + TRON_NILE_NODES, + &[TRON_NILE_TRC20_USDT_TICKER], + 90, + Some(HDAccountAddressId { + account_id: 77, + chain: Bip44Chain::External, + address_id: 7, + }), + )) + .expect("Expected TRX+TRC20 HD activation (account 77) to succeed"); + + let hd = match result { + EthWithTokensActivationResult::HD(hd) => hd, + _ => panic!("Expected HD activation result"), + }; + + let balance = match hd.wallet_balance { + EnableCoinBalanceMap::HD(hd_bal) => hd_bal, + _ => panic!("Expected EnableCoinBalanceMap::HD"), + }; + + let account = balance.accounts.first().expect("Expected first HD account entry"); + assert_eq!(account.account_index, 77, "Expected account_index=77"); + assert_eq!( + account.derivation_path, "m/44'/195'/77'", + "Unexpected account derivation_path" + ); + assert!( + account.addresses.len() >= 8, + "Expected at least 8 addresses (0..=7), got {}", + account.addresses.len() + ); + + let addr7 = &account.addresses[7]; + assert_eq!(addr7.derivation_path, "m/44'/195'/77'/0/7"); + match addr7.chain { + Bip44Chain::External => (), + Bip44Chain::Internal => panic!("Expected External chain for account 77, index 7"), + }; + TronAddress::from_base58(&addr7.address).expect("Invalid base58check TRON address for account 77, index 7"); + + // Validate TRC20 token balance is present at address 7 + assert!( + addr7.balance.contains_key(TRON_NILE_TRC20_USDT_TICKER), + "Expected TRC20 {} balance entry for account 77, address index 7", + TRON_NILE_TRC20_USDT_TICKER + ); + + block_on(mm.stop()).unwrap(); +} + +/// Test gap limit scanning - finds funded index 7 after unfunded gaps at 2..=6 +#[test] +fn test_trx_hd_gap_limit_scanning_finds_index_7_after_unfunded_gaps() { + let coins = serde_json::json!([trx_conf()]); + let conf = Mm2TestConf::seednode_with_hd_account(Mm2TestConfForSwap::BOB_HD_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + let result = block_on(task_enable_trx( + &mm, + TRON_NILE_NODES, + 60, + Some(HDAccountAddressId { + account_id: 0, + chain: Bip44Chain::External, + address_id: 7, + }), + )) + .expect("Expected TRX HD activation to succeed"); + + let hd = match result { + EthWithTokensActivationResult::HD(hd) => hd, + _ => panic!("Expected HD activation result"), + }; + + let balance = match hd.wallet_balance { + EnableCoinBalanceMap::HD(hd_bal) => hd_bal, + _ => panic!("Expected EnableCoinBalanceMap::HD"), + }; + + let account0 = balance.accounts.first().expect("Expected first HD account entry"); + assert!( + account0.addresses.len() >= 8, + "Expected at least 8 addresses (0..=7), got {}", + account0.addresses.len() + ); + + // Indices 2..=6 are expected to be unfunded + for i in 2usize..=6usize { + assert_eq!( + account0.addresses[i].derivation_path, + format!("m/44'/195'/0'/0/{}", i), + "Unexpected derivation_path at index {}", + i + ); + if let Some(trx_balance) = account0.addresses[i].balance.get("TRX") { + assert!( + trx_balance.spendable < 1.into(), + "Expected index {} to be unfunded (< 1 TRX), got {:?}", + i, + trx_balance.spendable + ); + } + } + + // Index 7 is funded + assert_eq!(account0.addresses[7].address, "TBic1drXQNM1BiBevg751GsZtv59GWb6ZK"); + let spendable7 = &account0.addresses[7].balance.get("TRX").unwrap().spendable; + assert!( + *spendable7 > 3.into(), + "Expected index 7 to be funded (~5 TRX), got {:?}", + spendable7 + ); + + block_on(mm.stop()).unwrap(); +} + +/// Test HD scanning detects addresses with transaction history but zero balance. +/// Uses TRON_USED_ZERO_BALANCE_PASSPHRASE: +/// - Index 0: funded (~1777.8 TRX) +/// - Index 2: has tx history but 0 balance (used but empty) +#[test] +fn test_trx_hd_scanning_detects_used_but_zero_balance_address() { + let coins = serde_json::json!([trx_conf()]); + let conf = Mm2TestConf::seednode_with_hd_account(TRON_USED_ZERO_BALANCE_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + let result = block_on(task_enable_trx( + &mm, + TRON_NILE_NODES, + 60, + Some(HDAccountAddressId { + account_id: 0, + chain: Bip44Chain::External, + address_id: 0, + }), + )) + .expect("Expected TRX HD activation to succeed"); + + let hd = match result { + EthWithTokensActivationResult::HD(hd) => hd, + _ => panic!("Expected HD activation result"), + }; + + let balance = match hd.wallet_balance { + EnableCoinBalanceMap::HD(hd_bal) => hd_bal, + _ => panic!("Expected EnableCoinBalanceMap::HD"), + }; + + let account0 = balance.accounts.first().expect("Expected first HD account entry"); + assert!( + account0.addresses.len() >= 3, + "Expected at least 3 addresses (0, 1, 2), got {}", + account0.addresses.len() + ); + + // Index 0 should be funded + assert_eq!( + account0.addresses[0].address, "TSqB9tqfaQ1DYSdMCbVSLPzQsaNVjeu9hq", + "Unexpected address at index 0" + ); + let spendable0 = &account0.addresses[0].balance.get("TRX").unwrap().spendable; + assert!( + *spendable0 > 100.into(), + "Expected index 0 to have a funded TRX balance (public testnet mnemonic, balance may decrease over time), got {:?}", + spendable0 + ); + + // Index 2 should be detected via gap limit scanning (has tx history) but have zero balance + assert_eq!( + account0.addresses[2].address, "TPoJwueR4xfZCXuQTYqem4edQgoM3uV78n", + "Unexpected address at index 2" + ); + + // Verify index 2 has strictly zero balance + if let Some(trx_balance) = account0.addresses[2].balance.get("TRX") { + assert!( + trx_balance.spendable == 0.into(), + "Expected index 2 to have exactly 0 TRX, got {:?}", + trx_balance.spendable + ); + } + + block_on(mm.stop()).unwrap(); +} + +// ============================================================================= +// TRC20 Token Tests +// ============================================================================= + +/// Test TRC20 activation via enable_erc20_token_v2 after TRX is already active. +#[test] +fn test_trc20_activation_after_platform() { + TronAddress::from_base58(TRON_NILE_TRC20_USDT_CONTRACT).expect("Invalid TRC20 Base58 contract address constant"); + + let coins = serde_json::json!([trx_conf(), trc20_usdt_nile_conf()]); + let conf = Mm2TestConf::seednode_with_hd_account(Mm2TestConfForSwap::BOB_HD_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + block_on(task_enable_trx(&mm, TRON_NILE_NODES, 90, None)).expect("Expected TRX activation to succeed"); + + let token_activation = block_on(enable_erc20_token_v2(&mm, TRON_NILE_TRC20_USDT_TICKER, None, 90, None)) + .expect("Expected TRC20 token activation to succeed after TRX is active"); + + assert_eq!( + token_activation.platform_coin, "TRX", + "Expected platform_coin to be TRX" + ); + + TronAddress::from_base58(&token_activation.token_contract_address) + .expect("Invalid base58check TRC20 contract address returned in activation result"); + + // Validate TRC20 token balance is present at specific address + let balance = match token_activation.wallet_balance { + EnableCoinBalanceMap::HD(hd_bal) => hd_bal, + _ => panic!("Expected EnableCoinBalanceMap::HD"), + }; + let account0 = balance.accounts.first().expect("Expected first HD account entry"); + assert!( + account0.addresses[0].balance.contains_key(TRON_NILE_TRC20_USDT_TICKER), + "Expected TRC20 {} balance entry for address index 0", + TRON_NILE_TRC20_USDT_TICKER + ); + + block_on(mm.stop()).unwrap(); +} + +/// Test TRC20 HD activation with specific derivation path. +#[test] +fn test_trc20_hd_activation_with_path() { + TronAddress::from_base58(TRON_NILE_TRC20_USDT_CONTRACT).expect("Invalid TRC20 Base58 contract address constant"); + + let coins = serde_json::json!([trx_conf(), trc20_usdt_nile_conf()]); + let conf = Mm2TestConf::seednode_with_hd_account(Mm2TestConfForSwap::BOB_HD_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + let path_to_address = HDAccountAddressId { + account_id: 0, + chain: Bip44Chain::External, + address_id: 0, + }; + + block_on(task_enable_trx(&mm, TRON_NILE_NODES, 90, Some(path_to_address.clone()))) + .expect("Expected TRX HD activation to succeed"); + + let token_activation = block_on(enable_erc20_token_v2( + &mm, + TRON_NILE_TRC20_USDT_TICKER, + None, + 90, + Some(path_to_address.clone()), + )) + .expect("Expected TRC20 token activation in HD mode to succeed"); + + assert_eq!( + token_activation.platform_coin, "TRX", + "Expected platform_coin to be TRX" + ); + + TronAddress::from_base58(&token_activation.token_contract_address) + .expect("Invalid base58check TRC20 contract address returned in activation result"); + + // Validate TRC20 token balance and derivation path at specific address + let balance = match token_activation.wallet_balance { + EnableCoinBalanceMap::HD(hd_bal) => hd_bal, + _ => panic!("Expected EnableCoinBalanceMap::HD"), + }; + let account0 = balance.accounts.first().expect("Expected first HD account entry"); + assert!( + account0.addresses[0].balance.contains_key(TRON_NILE_TRC20_USDT_TICKER), + "Expected TRC20 {} balance entry for address index 0", + TRON_NILE_TRC20_USDT_TICKER + ); + assert_eq!( + account0.addresses[0].derivation_path, "m/44'/195'/0'/0/0", + "Unexpected derivation path for address index 0" + ); + + block_on(mm.stop()).unwrap(); +} + +/// Test TRC20-only address detection during HD gap scanning. +/// +/// Index 10 holds only TRC20 USDT (no TRX) and sits beyond the last TRX-funded address (index 7). +/// If `is_address_used()` didn't check TRC20 balances, the scanner would treat index 10 as empty. +/// +/// Funding setup (BOB_HD_PASSPHRASE on Nile testnet): +/// - Index 0, 1, 7: TRX (native balance) +/// - Index 10: TRC20 USDT only (5 USDT, no TRX) +/// - Index 8, 9: Nothing (gap addresses) +#[test] +fn test_trc20_hd_gap_scanning() { + let coins = serde_json::json!([trx_conf(), trc20_usdt_nile_conf()]); + let conf = Mm2TestConf::seednode_with_hd_account(Mm2TestConfForSwap::BOB_HD_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + // Activate at index 0 and let gap scanning (limit=20) discover all used addresses. + // The scanner walks forward from 0; after the last TRX address (7), it continues for + // up to 20 consecutive unused addresses. Index 10 falls within that window. + let result = block_on(task_enable_trx_with_tokens( + &mm, + TRON_NILE_NODES, + &[TRON_NILE_TRC20_USDT_TICKER], + 120, + Some(HDAccountAddressId { + account_id: 0, + chain: Bip44Chain::External, + address_id: 0, + }), + )) + .expect("Expected TRX+TRC20 HD activation to succeed"); + + let hd = match result { + EthWithTokensActivationResult::HD(hd) => hd, + _ => panic!("Expected HD activation result"), + }; + + let balance = match hd.wallet_balance { + EnableCoinBalanceMap::HD(hd_bal) => hd_bal, + _ => panic!("Expected EnableCoinBalanceMap::HD"), + }; + + let account0 = balance.accounts.first().expect("Expected first HD account entry"); + assert!( + account0.addresses.len() >= 11, + "Expected at least 11 addresses (0..=10), got {}", + account0.addresses.len() + ); + + // Verify TRX-funded addresses (0, 1, 7) have balances + for idx in [0usize, 1usize, 7usize] { + assert!( + account0.addresses[idx].balance.contains_key("TRX"), + "Expected TRX balance entry for address index {}", + idx + ); + } + + // KEY TEST: Verify address index 10 is discovered via TRC20 activity alone. + // This is BEYOND the last TRX address (7), proving TRC20 detection works. + let addr10 = &account0.addresses[10]; + assert_eq!( + addr10.address, BOB_HD_TRC20_ONLY_ADDRESS_INDEX_10, + "Address at index 10 should match BOB_HD_TRC20_ONLY_ADDRESS_INDEX_10" + ); + + // Verify TRC20 balance is present AND non-zero (proves detection via TRC20) + let trc20_balance = addr10 + .balance + .get(TRON_NILE_TRC20_USDT_TICKER) + .expect("Expected TRC20 balance entry for TRC20-only address at index 10"); + assert!( + trc20_balance.spendable > 0.into(), + "TRC20 balance at index 10 should be non-zero (proves TRC20 detection), got: {}", + trc20_balance.spendable + ); + + // TRC20-only address should have zero TRX balance + let trx_balance = addr10 + .balance + .get("TRX") + .expect("Expected TRX balance entry for address at index 10"); + assert_eq!( + trx_balance.spendable, + 0.into(), + "TRC20-only address at index 10 should have zero TRX balance" + ); + + // Verify indices 8-9 are true gap addresses (empty balance maps). + // This contrasts with index 10 which has balances, proving TRC20 detection. + for idx in 8usize..=9usize { + assert!( + account0.addresses[idx].balance.is_empty(), + "Gap address index {} should have empty balance (unlike TRC20-detected index 10), got keys: {:?}", + idx, + account0.addresses[idx].balance.keys().collect::>() + ); + } + + block_on(mm.stop()).unwrap(); +} + +// ============================================================================= +// TRON Withdraw Integration Tests (Nile) +// ============================================================================= + +/// Withdraw addresses for TRON_WITHDRAW_TEST_PASSPHRASE on Nile testnet. +const TRON_WITHDRAW_ADDR_INDEX_0: &str = "TDcxD6E5wTzvqCJd4RfkGfw9NkCBdvYcV9"; +const TRON_WITHDRAW_ADDR_INDEX_1: &str = "TW9RqU6bTJnM4quyRbvTwm3xfSHgk718qU"; +/// Iguana mode address for TRON_WITHDRAW_TEST_PASSPHRASE. +const TRON_WITHDRAW_IGUANA_ADDR: &str = "TP7AtLenmsyLpVdKvKzCdHTyDcQgYYzK4i"; + +fn withdraw_from_index(index: u32) -> HDAccountAddressId { + HDAccountAddressId { + account_id: 0, + chain: Bip44Chain::External, + address_id: index, + } +} + +/// Extract the total fee from a withdraw's fee_details, asserting it's `TxFeeDetails::Tron`. +fn tron_total_fee(tx: &TransactionDetails) -> BigDecimal { + let fee: TxFeeDetails = serde_json::from_value(tx.fee_details.clone()).unwrap(); + match fee { + TxFeeDetails::Tron(tron_fee) => tron_fee.total_fee, + other => panic!("Expected TxFeeDetails::Tron, got {:?}", other), + } +} + +/// Build a standalone [`TronApiClient`] from [`TRON_NILE_NODES`] for on-chain verification. +fn nile_api_client() -> TronApiClient { + let clients = TRON_NILE_NODES + .iter() + .map(|url| { + TronHttpClient::new( + TronHttpNode { + uri: url.parse().expect("valid Nile node URL"), + komodo_proxy: false, + }, + None, + ) + }) + .collect(); + TronApiClient::new(clients) +} + +/// Query Nile testnet to verify a broadcast transaction exists on-chain. +/// Uses [`TronApiClient`] with node rotation/failover. +fn verify_tx_on_nile(tx_hash: &str) { + // Brief pause for tx propagation to Nile full nodes. + std::thread::sleep(std::time::Duration::from_secs(3)); + + let tx_hash_hex = tx_hash.strip_prefix("0x").unwrap_or(tx_hash); + let client = nile_api_client(); + let resp = + block_on(client.get_transaction_by_id(tx_hash_hex)).expect("get_transaction_by_id failed on all Nile nodes"); + assert_eq!( + resp.tx_id.to_lowercase(), + tx_hash_hex.to_lowercase(), + "Nile txID should match our tx_hash" + ); +} + +/// Test TRX withdraw + broadcast (iguana mode). +#[test] +fn test_trx_withdraw_and_send() { + let coins = serde_json::json!([trx_conf()]); + let conf = Mm2TestConf::seednode(TRON_WITHDRAW_TEST_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + block_on(enable_trx_with_tokens(&mm, TRON_NILE_NODES, &[])); + + // Pre-withdraw balance sanity check + let balance_before = block_on(my_balance(&mm, "TRX")); + assert_eq!(balance_before.coin, "TRX"); + assert!(balance_before.balance > BigDecimal::from(1), "Need > 1 TRX to withdraw"); + + let tx_details = block_on(withdraw_v1(&mm, "TRX", TRON_WITHDRAW_ADDR_INDEX_0, "1", None)); + + // Exact amounts and addresses + assert_eq!(tx_details.coin, "TRX"); + assert_eq!(tx_details.total_amount, BigDecimal::from(1)); + assert_eq!(tx_details.from, vec![TRON_WITHDRAW_IGUANA_ADDR.to_owned()]); + assert_eq!(tx_details.to, vec![TRON_WITHDRAW_ADDR_INDEX_0.to_owned()]); + assert_eq!(tx_details.received_by_me, BigDecimal::default()); + + // TRX native: fee is deducted from same balance β†’ spent_by_me = amount + fee + let fee = tron_total_fee(&tx_details); + assert_eq!(tx_details.spent_by_me, &tx_details.total_amount + &fee); + assert_eq!( + tx_details.my_balance_change, + &tx_details.received_by_me - &tx_details.spent_by_me + ); + + let send_result = block_on(send_raw_transaction(&mm, "TRX", &tx_details.tx_hex)); + assert_eq!(send_result["tx_hash"].as_str().unwrap(), tx_details.tx_hash); + + verify_tx_on_nile(&tx_details.tx_hash); + + block_on(mm.stop()).unwrap(); +} + +/// Test TRC20 USDT withdraw + broadcast (iguana mode). +#[test] +fn test_trc20_withdraw_and_send() { + let coins = serde_json::json!([trx_conf(), trc20_usdt_nile_conf()]); + let conf = Mm2TestConf::seednode(TRON_WITHDRAW_TEST_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + block_on(enable_trx_with_tokens( + &mm, + TRON_NILE_NODES, + &[TRON_NILE_TRC20_USDT_TICKER], + )); + + // Pre-withdraw balance sanity checks + let trx_balance = block_on(my_balance(&mm, "TRX")); + assert!(trx_balance.balance > BigDecimal::from(0), "Need TRX for fees"); + let token_balance = block_on(my_balance(&mm, TRON_NILE_TRC20_USDT_TICKER)); + assert!(token_balance.balance >= BigDecimal::from(1), "Need >= 1 USDT"); + + let tx_details = block_on(withdraw_v1( + &mm, + TRON_NILE_TRC20_USDT_TICKER, + TRON_WITHDRAW_ADDR_INDEX_0, + "1", + None, + )); + + // Exact amounts and addresses + assert_eq!(tx_details.coin, TRON_NILE_TRC20_USDT_TICKER); + assert_eq!(tx_details.total_amount, BigDecimal::from(1)); + assert_eq!(tx_details.from, vec![TRON_WITHDRAW_IGUANA_ADDR.to_owned()]); + assert_eq!(tx_details.to, vec![TRON_WITHDRAW_ADDR_INDEX_0.to_owned()]); + assert_eq!(tx_details.received_by_me, BigDecimal::default()); + + // TRC20: fee is paid in TRX, not the token β†’ spent_by_me = amount, balance_change = -amount + assert_eq!(tx_details.spent_by_me, tx_details.total_amount.clone()); + assert_eq!( + tx_details.my_balance_change, + &tx_details.received_by_me - &tx_details.spent_by_me + ); + + let fee: TxFeeDetails = serde_json::from_value(tx_details.fee_details.clone()).unwrap(); + match fee { + TxFeeDetails::Tron(ref tron_fee) => { + assert!(tron_fee.energy_used > 0, "TRC20 transfer should use energy"); + assert_eq!(tron_fee.coin, "TRX", "Fees should be paid in TRX"); + }, + other => panic!("Expected TxFeeDetails::Tron, got {:?}", other), + } + + let send_result = block_on(send_raw_transaction( + &mm, + TRON_NILE_TRC20_USDT_TICKER, + &tx_details.tx_hex, + )); + assert_eq!(send_result["tx_hash"].as_str().unwrap(), tx_details.tx_hash); + + verify_tx_on_nile(&tx_details.tx_hash); + + block_on(mm.stop()).unwrap(); +} + +/// Test TRX withdraw max (iguana mode). Does NOT broadcast to preserve test wallet balance. +#[test] +fn test_trx_withdraw_max() { + let coins = serde_json::json!([trx_conf()]); + let conf = Mm2TestConf::seednode(TRON_WITHDRAW_TEST_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + block_on(enable_trx_with_tokens(&mm, TRON_NILE_NODES, &[])); + + // Capture balance before max withdraw + let balance_before = block_on(my_balance(&mm, "TRX")); + assert!(balance_before.balance > BigDecimal::from(0), "Need TRX balance"); + + let withdraw = block_on(mm.rpc(&serde_json::json!({ + "userpass": mm.userpass, + "method": "withdraw", + "coin": "TRX", + "to": TRON_WITHDRAW_ADDR_INDEX_0, + "max": true, + }))) + .unwrap(); + assert!(withdraw.0.is_success(), "Max withdraw failed: {}", withdraw.1); + + let tx_details: TransactionDetails = serde_json::from_str(&withdraw.1).unwrap(); + + // Exact addresses + assert_eq!(tx_details.coin, "TRX"); + assert_eq!(tx_details.from, vec![TRON_WITHDRAW_IGUANA_ADDR.to_owned()]); + assert_eq!(tx_details.to, vec![TRON_WITHDRAW_ADDR_INDEX_0.to_owned()]); + assert_eq!(tx_details.received_by_me, BigDecimal::default()); + + // Max withdraw: spent_by_me β‰ˆ balance (may leave up to ~0.001 TRX dust at varint boundaries). + // Fee can be zero when the account has enough free bandwidth. + let fee = tron_total_fee(&tx_details); + assert!(tx_details.total_amount > BigDecimal::from(0)); + let dust = &balance_before.balance - &tx_details.spent_by_me; + let max_dust = BigDecimal::from_str("0.001").unwrap(); // ~1000 SUN varint boundary tolerance + assert!( + dust >= BigDecimal::from(0) && dust <= max_dust, + "Max withdraw dust {} exceeds tolerance {}", + dust, + max_dust + ); + assert_eq!(tx_details.spent_by_me, &tx_details.total_amount + &fee); + assert_eq!( + tx_details.my_balance_change, + &tx_details.received_by_me - &tx_details.spent_by_me + ); + + // Do NOT broadcast β€” would drain the test wallet. + block_on(mm.stop()).unwrap(); +} + +/// Test TRX withdraw from HD wallet (index 1). +#[test] +fn test_trx_withdraw_hd() { + let coins = serde_json::json!([trx_conf()]); + let conf = Mm2TestConf::seednode_with_hd_account(TRON_WITHDRAW_TEST_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + block_on(task_enable_trx_with_tokens( + &mm, + TRON_NILE_NODES, + &[], + 180, + Some(withdraw_from_index(1)), + )) + .expect("TRX HD activation should succeed"); + + let tx_details = block_on(withdraw_v1( + &mm, + "TRX", + TRON_WITHDRAW_ADDR_INDEX_0, + "0.5", + Some(withdraw_from_index(1)), + )); + + // Exact amounts and addresses + assert_eq!(tx_details.coin, "TRX"); + assert_eq!(tx_details.total_amount, BigDecimal::from_str("0.5").unwrap()); + assert_eq!(tx_details.from, vec![TRON_WITHDRAW_ADDR_INDEX_1.to_owned()]); + assert_eq!(tx_details.to, vec![TRON_WITHDRAW_ADDR_INDEX_0.to_owned()]); + assert_eq!(tx_details.received_by_me, BigDecimal::default()); + + // TRX native: fee deducted from same balance + let fee = tron_total_fee(&tx_details); + assert_eq!(tx_details.spent_by_me, &tx_details.total_amount + &fee); + assert_eq!( + tx_details.my_balance_change, + &tx_details.received_by_me - &tx_details.spent_by_me + ); + + let send_result = block_on(send_raw_transaction(&mm, "TRX", &tx_details.tx_hex)); + assert_eq!(send_result["tx_hash"].as_str().unwrap(), tx_details.tx_hash); + + verify_tx_on_nile(&tx_details.tx_hash); + + block_on(mm.stop()).unwrap(); +} + +/// Test TRC20 USDT withdraw from HD wallet (index 1). +#[test] +fn test_trc20_withdraw_hd() { + let coins = serde_json::json!([trx_conf(), trc20_usdt_nile_conf()]); + let conf = Mm2TestConf::seednode_with_hd_account(TRON_WITHDRAW_TEST_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + block_on(task_enable_trx_with_tokens( + &mm, + TRON_NILE_NODES, + &[TRON_NILE_TRC20_USDT_TICKER], + 180, + Some(withdraw_from_index(1)), + )) + .expect("TRX+TRC20 HD activation should succeed"); + + let tx_details = block_on(withdraw_v1( + &mm, + TRON_NILE_TRC20_USDT_TICKER, + TRON_WITHDRAW_ADDR_INDEX_0, + "0.5", + Some(withdraw_from_index(1)), + )); + + // Exact amounts and addresses + assert_eq!(tx_details.coin, TRON_NILE_TRC20_USDT_TICKER); + assert_eq!(tx_details.total_amount, BigDecimal::from_str("0.5").unwrap()); + assert_eq!(tx_details.from, vec![TRON_WITHDRAW_ADDR_INDEX_1.to_owned()]); + assert_eq!(tx_details.to, vec![TRON_WITHDRAW_ADDR_INDEX_0.to_owned()]); + assert_eq!(tx_details.received_by_me, BigDecimal::default()); + + // TRC20: fee is paid in TRX, not the token β†’ spent_by_me = amount, balance_change = -amount + assert_eq!(tx_details.spent_by_me, tx_details.total_amount.clone()); + assert_eq!( + tx_details.my_balance_change, + &tx_details.received_by_me - &tx_details.spent_by_me + ); + + let fee: TxFeeDetails = serde_json::from_value(tx_details.fee_details.clone()).unwrap(); + match fee { + TxFeeDetails::Tron(ref tron_fee) => { + assert!(tron_fee.energy_used > 0, "TRC20 transfer should use energy"); + assert_eq!(tron_fee.coin, "TRX", "Fees should be paid in TRX"); + }, + other => panic!("Expected TxFeeDetails::Tron, got {:?}", other), + } + + let send_result = block_on(send_raw_transaction( + &mm, + TRON_NILE_TRC20_USDT_TICKER, + &tx_details.tx_hex, + )); + assert_eq!(send_result["tx_hash"].as_str().unwrap(), tx_details.tx_hash); + + verify_tx_on_nile(&tx_details.tx_hash); + + block_on(mm.stop()).unwrap(); +} + +/// Test TRX withdraw from unfunded HD address (index 2) fails with insufficient balance. +#[test] +fn test_trx_withdraw_insufficient_balance() { + let coins = serde_json::json!([trx_conf()]); + let conf = Mm2TestConf::seednode_with_hd_account(TRON_WITHDRAW_TEST_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + block_on(task_enable_trx_with_tokens( + &mm, + TRON_NILE_NODES, + &[], + 180, + Some(withdraw_from_index(2)), + )) + .expect("TRX HD activation should succeed"); + + let withdraw = block_on(mm.rpc(&serde_json::json!({ + "userpass": mm.userpass, + "method": "withdraw", + "coin": "TRX", + "to": TRON_WITHDRAW_ADDR_INDEX_0, + "amount": "1", + "from": withdraw_from_index(2), + }))) + .unwrap(); + + assert!( + !withdraw.0.is_success(), + "Withdraw from unfunded address should fail, got: {}", + withdraw.1 + ); + assert!( + withdraw.1.contains("Not enough TRX") || withdraw.1.contains("NotSufficientBalance"), + "Error should mention insufficient balance, got: {}", + withdraw.1 + ); + + block_on(mm.stop()).unwrap(); +} + +/// Test TRX fee details structure β€” validate all fields present and correct (no broadcast). +#[test] +fn test_trx_fee_details_structure() { + let coins = serde_json::json!([trx_conf()]); + let conf = Mm2TestConf::seednode(TRON_WITHDRAW_TEST_PASSPHRASE, &coins); + let mm = block_on(MarketMakerIt::start_async(conf.conf, conf.rpc_password, None)).unwrap(); + + block_on(enable_trx_with_tokens(&mm, TRON_NILE_NODES, &[])); + + let tx_details = block_on(withdraw_v1(&mm, "TRX", TRON_WITHDRAW_ADDR_INDEX_0, "0.1", None)); + + // Exact amounts and addresses + assert_eq!(tx_details.coin, "TRX"); + assert_eq!(tx_details.total_amount, BigDecimal::from_str("0.1").unwrap()); + assert_eq!(tx_details.from, vec![TRON_WITHDRAW_IGUANA_ADDR.to_owned()]); + assert_eq!(tx_details.to, vec![TRON_WITHDRAW_ADDR_INDEX_0.to_owned()]); + assert_eq!(tx_details.received_by_me, BigDecimal::default()); + + // TRX native: spent_by_me = amount + fee + let total_fee = tron_total_fee(&tx_details); + assert_eq!(tx_details.spent_by_me, &tx_details.total_amount + &total_fee); + assert_eq!( + tx_details.my_balance_change, + &tx_details.received_by_me - &tx_details.spent_by_me + ); + + // Validate fee_details has all expected fields with correct types + let fee_json = &tx_details.fee_details; + assert_eq!(fee_json["type"].as_str().unwrap(), "Tron", "fee type should be Tron"); + assert_eq!(fee_json["coin"].as_str().unwrap(), "TRX", "fee coin should be TRX"); + assert!( + fee_json["bandwidth_used"].as_u64().is_some(), + "bandwidth_used should be a number" + ); + assert!( + fee_json["bandwidth_used"].as_u64().unwrap() > 0, + "bandwidth_used should be > 0" + ); + assert_eq!( + fee_json["energy_used"].as_u64().unwrap(), + 0, + "energy_used should be 0 for TRX transfer" + ); + assert!( + fee_json["bandwidth_fee"].is_string(), + "bandwidth_fee should be a string (BigDecimal)" + ); + assert_eq!( + fee_json["energy_fee"].as_str().unwrap(), + "0.000000", + "energy_fee should be zero with fixed 6-decimal scale" + ); + assert!( + fee_json["total_fee"].is_string(), + "total_fee should be a string (BigDecimal)" + ); + + // total_fee should equal bandwidth_fee for a TRX transfer (no energy) + assert_eq!( + fee_json["total_fee"].as_str().unwrap(), + fee_json["bandwidth_fee"].as_str().unwrap(), + "total_fee should equal bandwidth_fee for TRX transfer" + ); + + // Also validate via typed deserialization + let fee: TxFeeDetails = serde_json::from_value(fee_json.clone()).unwrap(); + match fee { + TxFeeDetails::Tron(tron_fee) => { + assert_eq!(tron_fee.coin, "TRX"); + assert!(tron_fee.bandwidth_used > 0); + assert_eq!(tron_fee.energy_used, 0); + assert_eq!(tron_fee.energy_fee, 0.into()); + assert_eq!(tron_fee.total_fee, tron_fee.bandwidth_fee); + }, + other => panic!("Expected TxFeeDetails::Tron, got {:?}", other), + } + + // Do NOT broadcast β€” pure structure validation. + block_on(mm.stop()).unwrap(); +} diff --git a/mm2src/mm2_main/tests/mm2_tests/wallet_connect_tests.rs b/mm2src/mm2_main/tests/mm2_tests/wallet_connect_tests.rs new file mode 100644 index 0000000000..bafea5a608 --- /dev/null +++ b/mm2src/mm2_main/tests/mm2_tests/wallet_connect_tests.rs @@ -0,0 +1,143 @@ +use coins::PrivKeyActivationPolicy; +use common::executor::Timer; +use common::{block_on, log}; +use mm2_test_helpers::for_tests::{ + enable_utxo_v2_electrum, new_walletconnect_connection, start_swaps, wait_for_swaps_finish_and_check_status, + wait_for_walletconnect_session, MarketMakerIt, Mm2InitPrivKeyPolicy, Mm2TestConfForSwap, +}; +use mm2_test_helpers::structs::CreateConnectionResponse; +use serde_json::json; + +#[cfg(not(target_arch = "wasm32"))] +/// Perform a swap using WalletConnect protocol with two tBTC (testnet4) coins. +async fn perform_walletconnect_swap() { + let walletconnect_namespaces = json!({ + "required_namespaces": { + "bip122": { + "chains": [ + "bip122:00000000da84f2bafbbc53dee25a72ae" // Bitcoin testnet4 chain_id + ], + "methods": [ + "getAccountAddresses", // Needed for activation + "signMessage", // Might be needed for activation (when the wallet doesn't send the pubkeys from getAccountAddresses) + "signPsbt", // Needed for HTLC signing (but we use it for any signing as well) + ], + "events": [] + } + } + }); + let electrums = vec![ + json!({ "url": "testnet.aranguren.org:52001", "protocol": "TCP" }), + json!({ "url": "blackie.c3-soft.com:57010", "protocol": "SSL" }), + ]; + + // Create two tBTC coins with different coin names to test swapping them. + let coins: Vec<_> = (1..=2) + .map(|coin_number| { + json!({ + "coin": format!("tBTC-{coin_number}"), + "name": format!("tbitcoin-{coin_number}"), + "fname": format!("Bitcoin Testnet {coin_number}"), + "orderbook_ticker": format!("tBTC-{coin_number}"), + "sign_message_prefix": "Bitcoin Signed Message:\n", + "bech32_hrp": "tb", + "txfee": 0, + "pubtype": 111, + "p2shtype": 196, + "dust": 1000, + "txfee": 2000, + "segwit": true, + "address_format": { + "format": "segwit" + }, + "mm2": 1, + "is_testnet": true, + "required_confirmations": 0, + "protocol": { + "type": "UTXO", + "protocol_data": { + "chain_id": "bip122:00000000da84f2bafbbc53dee25a72ae" + } + }, + "derivation_path": "m/84'/1'", + }) + }) + .collect(); + let trading_pair = (coins[0]["coin"].as_str().unwrap(), coins[1]["coin"].as_str().unwrap()); + let coins = json!(coins); + + let bob_conf = Mm2TestConfForSwap::bob_conf_with_policy(&Mm2InitPrivKeyPolicy::GlobalHDAccount, &coins); + // Uncomment to test the refund case. The quickest way to test both refunds is to reject signing TakerPaymentSpend (the 4th signing prompt). + // This will force the taker to refund himself and after sometime the maker will also refund himself because he can't spend the TakerPayment anymore (as it's already refunded). + // Note that you need to run the test with `--features custom-swap-locktime` to enable the custom `payment_locktime` feature. + // bob_conf.conf["payment_locktime"] = (1 * 60).into(); + let mut mm_bob = MarketMakerIt::start_async(bob_conf.conf, bob_conf.rpc_password, None) + .await + .unwrap(); + + let (_bob_dump_log, _bob_dump_dashboard) = mm_bob.mm_dump(); + log!("Bob log path: {}", mm_bob.log_path.display()); + Timer::sleep(2.).await; + + let alice_conf = Mm2TestConfForSwap::alice_conf_with_policy( + &Mm2InitPrivKeyPolicy::GlobalHDAccount, + &coins, + &mm_bob.my_seed_addr(), + ); + // Uncomment to test the refund case + // alice_conf.conf["payment_locktime"] = (1 * 60).into(); + let mut mm_alice = MarketMakerIt::start_async(alice_conf.conf, alice_conf.rpc_password, None) + .await + .unwrap(); + + let (_alice_dump_log, _alice_dump_dashboard) = mm_alice.mm_dump(); + log!("Alice log path: {}", mm_alice.log_path.display()); + Timer::sleep(2.).await; + + for (mm, operator) in [(&mut mm_bob, "Bob"), (&mut mm_alice, "Alice")] { + // Create a WalletConnect connection. + let CreateConnectionResponse { url, pairing_topic } = + new_walletconnect_connection(mm, walletconnect_namespaces.clone()).await; + log!("{operator}'s WalletConnect connection:\n{url}\n\n"); + // Wait for the user to approve the connection and establish the session. + let session_topic = wait_for_walletconnect_session(mm, &pairing_topic, 300).await; + let priv_key_policy = PrivKeyActivationPolicy::WalletConnect { + session_topic: session_topic.into(), + }; + // Enable the coin pair for this operator. + let rc = enable_utxo_v2_electrum( + mm, + trading_pair.0, + electrums.clone(), + None, + 600, + Some(json!(priv_key_policy)), + ) + .await; + log!("enable {} ({operator}): {rc:?}", trading_pair.0); + let rc = enable_utxo_v2_electrum( + mm, + trading_pair.1, + electrums.clone(), + None, + 600, + Some(json!(priv_key_policy)), + ) + .await; + log!("enable {} ({operator}): {rc:?}", trading_pair.1); + } + + // Start the swap + let uuids = start_swaps(&mut mm_bob, &mut mm_alice, &[trading_pair], 1.0, 1.0, 0.0002).await; + // Wait for the swaps to finish (you need to accept signing the HTLCs in the WalletConnect in this stage). + wait_for_swaps_finish_and_check_status(&mut mm_bob, &mut mm_alice, &uuids, 0.0002, 1.0).await; + + mm_bob.stop().await.unwrap(); + mm_alice.stop().await.unwrap(); +} + +#[test] +#[ignore] +fn test_walletconnect_swap() { + block_on(perform_walletconnect_swap()); +} diff --git a/mm2src/mm2_main/tests/sia_tests/docker_functional_tests.rs b/mm2src/mm2_main/tests/sia_tests/docker_functional_tests.rs index c9881ce1a3..c1ca2310fa 100644 --- a/mm2src/mm2_main/tests/sia_tests/docker_functional_tests.rs +++ b/mm2src/mm2_main/tests/sia_tests/docker_functional_tests.rs @@ -1,4 +1,5 @@ -use crate::docker_tests::docker_tests_common::{fund_privkey_utxo, random_secp256k1_secret}; +use crate::docker_tests::helpers::env::random_secp256k1_secret; +use crate::docker_tests::helpers::utxo::fund_privkey_utxo; use super::utils::*; @@ -6,7 +7,7 @@ use coins::siacoin::{ApiClientHelpers, SiaTransactionTypes}; use mm2_number::BigDecimal; use mm2_test_helpers::for_tests::{start_swaps, wait_for_swap_finished_or_err}; use serde::Deserialize; -use serde_json::Value as Json; +use serde_json::{json, Value as Json}; use std::str::FromStr; diff --git a/mm2src/mm2_main/tests/sia_tests/mod.rs b/mm2src/mm2_main/tests/sia_tests/mod.rs index f5e712ddaa..9d8471286e 100644 --- a/mm2src/mm2_main/tests/sia_tests/mod.rs +++ b/mm2src/mm2_main/tests/sia_tests/mod.rs @@ -1,3 +1,8 @@ +//! Sia docker tests - requires `docker-tests-sia` feature. +//! +//! This module is gated at the crate level in docker_tests_main.rs with +//! `#[cfg(feature = "docker-tests-sia")]`. + mod docker_functional_tests; mod short_locktime_tests; diff --git a/mm2src/mm2_main/tests/sia_tests/short_locktime_tests.rs b/mm2src/mm2_main/tests/sia_tests/short_locktime_tests.rs index 7033535533..73308bc220 100644 --- a/mm2src/mm2_main/tests/sia_tests/short_locktime_tests.rs +++ b/mm2src/mm2_main/tests/sia_tests/short_locktime_tests.rs @@ -1,4 +1,5 @@ -use crate::docker_tests::docker_tests_common::{fund_privkey_utxo, random_secp256k1_secret}; +use crate::docker_tests::helpers::env::random_secp256k1_secret; +use crate::docker_tests::helpers::utxo::fund_privkey_utxo; use super::utils::*; diff --git a/mm2src/mm2_main/tests/sia_tests/utils.rs b/mm2src/mm2_main/tests/sia_tests/utils.rs index f609fbd70c..d6f8b9c90b 100644 --- a/mm2src/mm2_main/tests/sia_tests/utils.rs +++ b/mm2src/mm2_main/tests/sia_tests/utils.rs @@ -4,7 +4,7 @@ pub use coins::siacoin::sia_rust::utils::V2TransactionBuilder; use coins::siacoin::{ApiClientHelpers, SiaApiClient, SiaClient, SiaClientConf}; use keys::hash::H256; -use crate::docker_tests::docker_tests_common::SIA_RPC_PARAMS; +use crate::docker_tests::helpers::sia::SIA_RPC_PARAMS; use common::custom_futures::timeout::FutureTimerExt; use common::executor::Timer; use mm2_rpc::data::legacy::CoinInitResponse; @@ -12,7 +12,7 @@ use mm2_test_helpers::for_tests::{MarketMakerIt, Mm2TestConf}; use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; -use serde_json::Value as Json; +use serde_json::{json, Value as Json}; use std::collections::HashMap; use std::net::IpAddr; use std::time::Duration; @@ -72,7 +72,7 @@ pub const WALLETD_NETWORK_CONFIG: &str = r#"{ "failsafeAddress": "000000000000000000000000000000000000000000000000000000000000000089eb0d6a8a69" }, "hardforkV2": { - "allowHeight": 0, + "allowHeight": 30, "requireHeight": 7777777, "finalCutHeight": 8888888 } @@ -315,7 +315,11 @@ pub async fn wait_for_dsia_node_ready() { let client = init_sia_client().await.unwrap(); // Mine 155 blocks to begin because coinbase maturity is 150 + log!("Mining 155 blocks to Charlie's address..."); client.mine_blocks(155, &CHARLIE_SIA_ADDRESS).await.unwrap(); + // Verify blocks were mined + let height = client.current_height().await.unwrap(); + log!("Mining complete. Current height: {}", height); // Spawn a loop that will keep mining blocks every 10 seconds to advance the chain // and get the swap tests running. diff --git a/mm2src/mm2_main/tests/sia_tests/utils/komodod_client.rs b/mm2src/mm2_main/tests/sia_tests/utils/komodod_client.rs index 319c25bf1b..6d40980f3b 100644 --- a/mm2src/mm2_main/tests/sia_tests/utils/komodod_client.rs +++ b/mm2src/mm2_main/tests/sia_tests/utils/komodod_client.rs @@ -8,6 +8,7 @@ use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; use http::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; use reqwest::Client as ReqwestClient; +use serde_json::json; use std::net::IpAddr; use std::time::Duration; use url::Url; diff --git a/mm2src/mm2_p2p/AGENTS.md b/mm2src/mm2_p2p/AGENTS.md new file mode 100644 index 0000000000..96ae76fb9d --- /dev/null +++ b/mm2src/mm2_p2p/AGENTS.md @@ -0,0 +1,192 @@ +# mm2_p2p β€” Peer-to-Peer Networking + +> **Note:** Always follow the root `/CLAUDE.md` for global conventions (fmt, clippy, error handling, etc.). + +libp2p-based networking layer for decentralized communication between KDF nodes. + +## Responsibilities + +- P2P swarm management via `AtomicDexBehaviour` +- Gossipsub message broadcasting (orderbook, swaps, watchers) +- Request-response direct peer communication +- Peer discovery and exchange +- Relay node mesh maintenance +- Time synchronization validation between peers + +## Module Structure + +``` +src/ +β”œβ”€β”€ lib.rs # Exports, encode/decode_message, NetworkInfo +β”œβ”€β”€ behaviours/ +β”‚ β”œβ”€β”€ atomicdex.rs # AtomicDexBehaviour, spawn_gossipsub, AdexBehaviourCmd +β”‚ β”œβ”€β”€ peers_exchange.rs # Peer discovery protocol +β”‚ β”œβ”€β”€ request_response.rs # Direct peer requests +β”‚ β”œβ”€β”€ ping.rs # AdexPing liveness checks +β”‚ └── peer_store.rs # Peer address storage +β”œβ”€β”€ application/ # Application-layer protocols +β”‚ β”œβ”€β”€ request_response/ +β”‚ β”‚ β”œβ”€β”€ network_info.rs # NetworkInfoRequest +β”‚ β”‚ └── ordermatch.rs # Order matching requests +β”‚ └── network_event.rs # P2P network events +β”œβ”€β”€ p2p_ctx.rs # P2PContext for MmArc +β”œβ”€β”€ relay_address.rs # RelayAddress parsing +└── swarm_runtime.rs # SwarmRuntime async executor +``` + +## Core Types + +### AtomicDexBehaviour + +Main `NetworkBehaviour` combining multiple libp2p protocols: + +```rust +struct CoreBehaviour { + gossipsub: Gossipsub, // Pub-sub messaging + floodsub: Floodsub, // Peer address announcements + peers_exchange: PeersExchange, // Peer discovery + ping: AdexPing, // Liveness checks + request_response: RequestResponseBehaviour, // Direct requests +} +``` + +### AdexBehaviourCmd + +Commands sent to control the P2P swarm: + +```rust +enum AdexBehaviourCmd { + Subscribe { topic }, // Subscribe to gossipsub topic + Unsubscribe { topic }, // Unsubscribe from topic + PublishMsg { topic, msg }, // Broadcast message + PublishMsgFrom { topic, msg, from }, // Broadcast with source + RequestAnyRelay { req, response_tx }, // Request relays sequentially until success + RequestPeers { req, peers, response_tx }, // Request specific peers + RequestRelays { req, response_tx }, // Request all relays, collect responses + SendResponse { res, response_channel }, // Reply to request + GetPeersInfo { result_tx }, // Query connected peers + GetGossipMesh { result_tx }, // Get gossip mesh state + GetGossipPeerTopics { result_tx }, // Get topics per peer + GetGossipTopicPeers { result_tx }, // Get peers per topic + GetRelayMesh { result_tx }, // Get relay mesh + AddReservedPeer { peer, addresses }, // Add reserved peer + PropagateMessage { message_id, propagation_source }, // Forward message +} +``` + +### NodeType + +Determines node role in the network: + +```rust +enum NodeType { + Light { network_ports }, // Client node + Relay { ip, network_ports, wss_certs }, // Server/relay node + LightInMemory, // Testing + RelayInMemory { port }, // Testing +} +``` + +## Message Topics + +P2P messages are organized by topic prefix (defined in mm2_main): + +| Prefix | Purpose | Handler Location | +|--------|---------|------------------| +| `orbk` | Order broadcasts | `lp_ordermatch` | +| `swap` | Swap protocol messages (V1) | `lp_swap` | +| `swapv2` | Swap protocol messages (V2) | `lp_swap` | +| `swpwtchr` | Watcher coordination | `swap_watcher` | +| `txhlp` | Transaction helpers | `lp_swap` | +| `PEERS` | Peer address announcements | Floodsub (in mm2_p2p) | + +## Initialization Flow + +```rust +// 1. Create config +let mut config = GossipsubConfig::new(netid, runtime, node_type, p2p_key); +config.to_dial(seednodes); // Add seed nodes to dial + +// 2. Spawn swarm (returns cmd channel, event receiver, local peer ID) +let (cmd_tx, event_rx, peer_id) = spawn_gossipsub(config, on_poll).await?; + +// 3. Store context in MmArc +P2PContext::new(cmd_tx, keypair).store_to_mm_arc(&ctx); +``` + +## Key Invariants + +- **Time sync**: Peers with >20s clock skew are disconnected (`MAX_TIME_GAP_FOR_CONNECTED_PEER = 20`) +- **Mesh maintenance**: Relays maintain `mesh_n_low..mesh_n_high` connections (4-12 for relays, 2-6 for light) +- **Dial cooldown**: Recently dialed peers are skipped for 5 minutes (`DIAL_RETRY_DELAY = 300s`) +- **Message size**: Max ~1MB per message (`MAX_BUFFER_SIZE = 1024 * 1024 - 100`) +- **Default netid**: 6133 (`DEFAULT_NETID`) +- **Announce interval**: Peer address announcements every 600s + +## Request-Response Pattern + +Direct peer communication for queries: + +```rust +// Send request to any relay until success +let (response_tx, response_rx) = oneshot::channel(); +cmd_tx.send(AdexBehaviourCmd::RequestAnyRelay { + req: encoded_request, + response_tx, +}).await?; +let response = response_rx.await?; +``` + +Response types: +- `AdexResponse::Ok { response }` β€” Success with data +- `AdexResponse::None` β€” No data available +- `AdexResponse::Err { error }` β€” Error message + +## Interactions + +| Crate | Usage | +|-------|-------| +| **mm2_main** | `init_p2p()` spawns swarm, event loop processes messages | +| **mm2_core** | `P2PContext` stored in `MmArc` | +| **mm2_net** | `is_global_ipv4` address validation | +| **common** | Executor, SpawnFuture trait | +| **proxy_signature** | (Related) Message signing for proxy auth | + +## Transport Configuration + +- **Native**: DNS + TCP + WebSocket with Noise encryption, Yamux multiplexing +- **WASM**: WebSocket via browser API +- **Testing**: In-memory transport + +## Common Pitfalls + +| Issue | Solution | +|-------|----------| +| Peer not connecting | Check seednode addresses, verify netid matches | +| Messages not received | Confirm topic subscription via `GetGossipTopicPeers` | +| Time validation failing | Ensure system clock is synchronized | +| Too many/few connections | Adjust mesh_n parameters in GossipsubConfig | + +## Debugging Commands + +```rust +// Get connected peers +AdexBehaviourCmd::GetPeersInfo { result_tx } + +// Get gossip mesh state +AdexBehaviourCmd::GetGossipMesh { result_tx } + +// Get topics per peer +AdexBehaviourCmd::GetGossipPeerTopics { result_tx } + +// Get peers per topic +AdexBehaviourCmd::GetGossipTopicPeers { result_tx } + +// Get relay mesh +AdexBehaviourCmd::GetRelayMesh { result_tx } +``` + +## Tests + +- Unit: `cargo test -p mm2_p2p --lib` +- Integration: Tests spawn in-memory nodes with `RelayInMemory` diff --git a/mm2src/mm2_p2p/src/behaviours/atomicdex.rs b/mm2src/mm2_p2p/src/behaviours/atomicdex.rs index 62e5c22e90..16fb9360ba 100644 --- a/mm2src/mm2_p2p/src/behaviours/atomicdex.rs +++ b/mm2src/mm2_p2p/src/behaviours/atomicdex.rs @@ -35,11 +35,15 @@ use super::ping::AdexPing; use super::request_response::{ build_request_response_behaviour, PeerRequest, PeerResponse, RequestResponseBehaviour, RequestResponseSender, }; +#[cfg(feature = "application")] use crate::application::request_response::network_info::NetworkInfoRequest; +#[cfg(feature = "application")] use crate::application::request_response::P2PRequest; use crate::relay_address::{RelayAddress, RelayAddressError}; use crate::swarm_runtime::SwarmRuntime; -use crate::{decode_message, encode_message, NetworkInfo, NetworkPorts, RequestResponseBehaviourEvent}; +#[cfg(feature = "application")] +use crate::{decode_message, encode_message}; +use crate::{NetworkInfo, NetworkPorts, RequestResponseBehaviourEvent}; pub use libp2p::gossipsub::{Behaviour as Gossipsub, IdentTopic, MessageAuthenticity, MessageId, Topic, TopicHash}; pub use libp2p::gossipsub::{ @@ -57,7 +61,7 @@ const CONNECTED_RELAYS_CHECK_INTERVAL: Duration = Duration::from_secs(30); const ANNOUNCE_INTERVAL: Duration = Duration::from_secs(600); const ANNOUNCE_INITIAL_DELAY: Duration = Duration::from_secs(60); const CHANNEL_BUF_SIZE: usize = 1024 * 8; -const DEFAULT_NETID: u16 = 8762; +const DEFAULT_NETID: u16 = 6133; /// Used in time validation logic for each peer which runs immediately after the /// `ConnectionEstablished` event. @@ -75,7 +79,8 @@ lazy_static! { } pub const DEPRECATED_NETID_LIST: &[u16] = &[ - 7777, // TODO: keep it inaccessible until Q2 of 2024. + 7777, // Deprecated since netid migration to 8762 + 8762, // Deprecated since netid migration to 6133 ]; /// The structure is the same as `PeerResponse`, @@ -242,6 +247,7 @@ pub async fn get_relay_mesh(mut cmd_tx: AdexCmdTx) -> Vec { rx.await.expect("Tx should be present") } +#[cfg(feature = "application")] async fn validate_peer_time(peer: PeerId, mut response_tx: Sender, rp_sender: RequestResponseSender) { let request = P2PRequest::NetworkInfo(NetworkInfoRequest::GetPeerUtcTimestamp); let encoded_request = encode_message(&request) @@ -824,7 +830,9 @@ fn start_gossipsub( let mut announce_interval = Ticker::new_with_next(ANNOUNCE_INTERVAL, ANNOUNCE_INITIAL_DELAY); let mut listening = false; - let (timestamp_tx, mut timestamp_rx) = futures::channel::mpsc::channel(mesh_n_high); + #[cfg(feature = "application")] + let (timestamp_tx, mut timestamp_rx) = futures::channel::mpsc::channel::(mesh_n_high); + let polling_fut = poll_fn(move |cx: &mut Context| { loop { match swarm.behaviour_mut().cmd_rx.poll_next_unpin(cx) { @@ -834,6 +842,7 @@ fn start_gossipsub( } } + #[cfg(feature = "application")] while let Poll::Ready(Some(peer_id)) = timestamp_rx.poll_next_unpin(cx) { if swarm.disconnect_peer_id(peer_id).is_err() { error!("Disconnection from `{peer_id}` failed unexpectedly, which should never happen."); @@ -846,6 +855,7 @@ fn start_gossipsub( Poll::Ready(Some(event)) => { debug!("Swarm event {:?}", event); + #[cfg(feature = "application")] if let SwarmEvent::ConnectionEstablished { peer_id, .. } = &event { info!("Validating time data for peer `{peer_id}`."); let future = validate_peer_time( diff --git a/mm2src/mm2_test_helpers/Cargo.toml b/mm2src/mm2_test_helpers/Cargo.toml index d727180f56..478845fa64 100644 --- a/mm2src/mm2_test_helpers/Cargo.toml +++ b/mm2src/mm2_test_helpers/Cargo.toml @@ -13,6 +13,7 @@ common = { path = "../common" } crypto = { path = "../crypto" } db_common = { path = "../db_common" } futures = { version = "0.3", package = "futures", features = ["compat", "async-await", "thread-pool"] } +hex = "0.4.2" http = "0.2" lazy_static = "1.4" mm2_core = { path = "../mm2_core" } @@ -21,6 +22,7 @@ mm2_metrics = { path = "../mm2_metrics" } mm2_net = { path = "../mm2_net" } mm2_number = { path = "../mm2_number" } mm2_rpc = { path = "../mm2_rpc" } +kdf_walletconnect = { path = "../kdf_walletconnect" } rand = { version = "0.7", features = ["std", "small_rng", "wasm-bindgen"] } regex = "1" rpc = { path = "../mm2_bitcoin/rpc" } diff --git a/mm2src/mm2_test_helpers/contract_bytes/swap_contract_bytes b/mm2src/mm2_test_helpers/contract_bytes/swap_contract_bytes index fea8557914..c02d445ddd 100644 --- a/mm2src/mm2_test_helpers/contract_bytes/swap_contract_bytes +++ b/mm2src/mm2_test_helpers/contract_bytes/swap_contract_bytes @@ -1 +1 @@ -608060405234801561001057600080fd5b50611437806100206000396000f3fe60806040526004361061004a5760003560e01c806302ed292b1461004f5780630716326d146100de578063152cf3af1461017b57806346fc0294146101f65780639b415b2a14610294575b600080fd5b34801561005b57600080fd5b506100dc600480360360a081101561007257600080fd5b81019080803590602001909291908035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050610339565b005b3480156100ea57600080fd5b506101176004803603602081101561010157600080fd5b8101908080359060200190929190505050610867565b60405180846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526020018367ffffffffffffffff1667ffffffffffffffff16815260200182600381111561016557fe5b60ff168152602001935050505060405180910390f35b6101f46004803603608081101561019157600080fd5b8101908080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080356bffffffffffffffffffffffff19169060200190929190803567ffffffffffffffff1690602001909291905050506108bf565b005b34801561020257600080fd5b50610292600480360360a081101561021957600080fd5b81019080803590602001909291908035906020019092919080356bffffffffffffffffffffffff19169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190505050610bd9565b005b610337600480360360c08110156102aa57600080fd5b810190808035906020019092919080359060200190929190803573ffffffffffffffffffffffffffffffffffffffff169060200190929190803573ffffffffffffffffffffffffffffffffffffffff16906020019092919080356bffffffffffffffffffffffff19169060200190929190803567ffffffffffffffff169060200190929190505050610fe2565b005b6001600381111561034657fe5b600080878152602001908152602001600020600001601c9054906101000a900460ff16600381111561037457fe5b1461037e57600080fd5b6000600333836003600288604051602001808281526020019150506040516020818303038152906040526040518082805190602001908083835b602083106103db57805182526020820191506020810190506020830392506103b8565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa15801561041d573d6000803e3d6000fd5b5050506040513d602081101561043257600080fd5b8101908080519060200190929190505050604051602001808281526020019150506040516020818303038152906040526040518082805190602001908083835b602083106104955780518252602082019150602081019050602083039250610472565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa1580156104d7573d6000803e3d6000fd5b5050506040515160601b8689604051602001808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b81526014018573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526014018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401828152602001955050505050506040516020818303038152906040526040518082805190602001908083835b602083106105fc57805182526020820191506020810190506020830392506105d9565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa15801561063e573d6000803e3d6000fd5b5050506040515160601b905060008087815260200190815260200160002060000160009054906101000a900460601b6bffffffffffffffffffffffff1916816bffffffffffffffffffffffff19161461069657600080fd5b6002600080888152602001908152602001600020600001601c6101000a81548160ff021916908360038111156106c857fe5b0217905550600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff16141561074e573373ffffffffffffffffffffffffffffffffffffffff166108fc869081150290604051600060405180830381858888f19350505050158015610748573d6000803e3d6000fd5b50610820565b60008390508073ffffffffffffffffffffffffffffffffffffffff1663a9059cbb33886040518363ffffffff1660e01b8152600401808373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200182815260200192505050602060405180830381600087803b1580156107da57600080fd5b505af11580156107ee573d6000803e3d6000fd5b505050506040513d602081101561080457600080fd5b810190808051906020019092919050505061081e57600080fd5b505b7f36c177bcb01c6d568244f05261e2946c8c977fa50822f3fa098c470770ee1f3e8685604051808381526020018281526020019250505060405180910390a1505050505050565b60006020528060005260406000206000915090508060000160009054906101000a900460601b908060000160149054906101000a900467ffffffffffffffff169080600001601c9054906101000a900460ff16905083565b600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff16141580156108fc5750600034115b801561094057506000600381111561091057fe5b600080868152602001908152602001600020600001601c9054906101000a900460ff16600381111561093e57fe5b145b61094957600080fd5b60006003843385600034604051602001808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b81526014018573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526014018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401828152602001955050505050506040516020818303038152906040526040518082805190602001908083835b60208310610a6c5780518252602082019150602081019050602083039250610a49565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa158015610aae573d6000803e3d6000fd5b5050506040515160601b90506040518060600160405280826bffffffffffffffffffffffff191681526020018367ffffffffffffffff16815260200160016003811115610af757fe5b81525060008087815260200190815260200160002060008201518160000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908360601c021790555060208201518160000160146101000a81548167ffffffffffffffff021916908367ffffffffffffffff160217905550604082015181600001601c6101000a81548160ff02191690836003811115610b9357fe5b02179055509050507fccc9c05183599bd3135da606eaaf535daffe256e9de33c048014cffcccd4ad57856040518082815260200191505060405180910390a15050505050565b60016003811115610be657fe5b600080878152602001908152602001600020600001601c9054906101000a900460ff166003811115610c1457fe5b14610c1e57600080fd5b600060038233868689604051602001808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b81526014018573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526014018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401828152602001955050505050506040516020818303038152906040526040518082805190602001908083835b60208310610d405780518252602082019150602081019050602083039250610d1d565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa158015610d82573d6000803e3d6000fd5b5050506040515160601b905060008087815260200190815260200160002060000160009054906101000a900460601b6bffffffffffffffffffffffff1916816bffffffffffffffffffffffff1916148015610e10575060008087815260200190815260200160002060000160149054906101000a900467ffffffffffffffff1667ffffffffffffffff164210155b610e1957600080fd5b6003600080888152602001908152602001600020600001601c6101000a81548160ff02191690836003811115610e4b57fe5b0217905550600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff161415610ed1573373ffffffffffffffffffffffffffffffffffffffff166108fc869081150290604051600060405180830381858888f19350505050158015610ecb573d6000803e3d6000fd5b50610fa3565b60008390508073ffffffffffffffffffffffffffffffffffffffff1663a9059cbb33886040518363ffffffff1660e01b8152600401808373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200182815260200192505050602060405180830381600087803b158015610f5d57600080fd5b505af1158015610f71573d6000803e3d6000fd5b505050506040513d6020811015610f8757600080fd5b8101908080519060200190929190505050610fa157600080fd5b505b7f1797d500133f8e427eb9da9523aa4a25cb40f50ebc7dbda3c7c81778973f35ba866040518082815260200191505060405180910390a1505050505050565b600073ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff161415801561101f5750600085115b801561106357506000600381111561103357fe5b600080888152602001908152602001600020600001601c9054906101000a900460ff16600381111561106157fe5b145b61106c57600080fd5b60006003843385888a604051602001808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b81526014018573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401846bffffffffffffffffffffffff19166bffffffffffffffffffffffff191681526014018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b8152601401828152602001955050505050506040516020818303038152906040526040518082805190602001908083835b6020831061118e578051825260208201915060208101905060208303925061116b565b6001836020036101000a038019825116818451168082178552505050505050905001915050602060405180830381855afa1580156111d0573d6000803e3d6000fd5b5050506040515160601b90506040518060600160405280826bffffffffffffffffffffffff191681526020018367ffffffffffffffff1681526020016001600381111561121957fe5b81525060008089815260200190815260200160002060008201518160000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908360601c021790555060208201518160000160146101000a81548167ffffffffffffffff021916908367ffffffffffffffff160217905550604082015181600001601c6101000a81548160ff021916908360038111156112b557fe5b021790555090505060008590508073ffffffffffffffffffffffffffffffffffffffff166323b872dd33308a6040518463ffffffff1660e01b8152600401808473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018281526020019350505050602060405180830381600087803b15801561137d57600080fd5b505af1158015611391573d6000803e3d6000fd5b505050506040513d60208110156113a757600080fd5b81019080805190602001909291905050506113c157600080fd5b7fccc9c05183599bd3135da606eaaf535daffe256e9de33c048014cffcccd4ad57886040518082815260200191505060405180910390a1505050505050505056fea265627a7a723158208c83db436905afce0b7be1012be64818c49323c12d451fe2ab6bce76ff6421c964736f6c63430005110032 \ No newline at end of file +6080604052348015600f57600080fd5b5061135a8061001f6000396000f3fe60806040526004361061005a5760003560e01c8063152cf3af11610043578063152cf3af1461011257806346fc0294146101255780639b415b2a1461014557600080fd5b806302ed292b1461005f5780630716326d14610081575b600080fd5b34801561006b57600080fd5b5061007f61007a3660046110b4565b610165565b005b34801561008d57600080fd5b506100fa61009c366004611104565b600060208190529081526040902054606081901b9074010000000000000000000000000000000000000000810467ffffffffffffffff16907c0100000000000000000000000000000000000000000000000000000000900460ff1683565b6040516101099392919061114c565b60405180910390f35b61007f6101203660046111ec565b6104e9565b34801561013157600080fd5b5061007f610140366004611239565b610836565b34801561015157600080fd5b5061007f610160366004611276565b610b17565b60016000868152602081905260409020547c0100000000000000000000000000000000000000000000000000000000900460ff1660038111156101aa576101aa61111d565b146102225760405162461bcd60e51b815260206004820152602a60248201527f496e76616c6964207061796d656e742073746174652e204d757374206265205060448201527f61796d656e7453656e740000000000000000000000000000000000000000000060648201526084015b60405180910390fd5b600060033383600360028860405160200161023f91815260200190565b60408051601f1981840301815290829052610259916112dc565b602060405180830381855afa158015610276573d6000803e3d6000fd5b5050506040513d601f19601f82011682018060405250810190610299919061130b565b6040516020016102ab91815260200190565b60408051601f19818403018152908290526102c5916112dc565b602060405180830381855afa1580156102e2573d6000803e3d6000fd5b505060405180516bffffffffffffffffffffffff19606095861b8116602084015293851b84166034830152841b831660488201529287901b909116605c830152506070810187905260900160408051601f1981840301815290829052610347916112dc565b602060405180830381855afa158015610364573d6000803e3d6000fd5b50506040805151600089815260208190529190912054606091821b9350901b6bffffffffffffffffffffffff199081169083161490506103e65760405162461bcd60e51b815260206004820152601360248201527f496e76616c6964207061796d656e7448617368000000000000000000000000006044820152606401610219565b6000868152602081815260409182902080547fffffff00ffffffffffffffffffffffffffffffffffffffffffffffffffffffff167c020000000000000000000000000000000000000000000000000000000017905581518881529081018690527f36c177bcb01c6d568244f05261e2946c8c977fa50822f3fa098c470770ee1f3e91015b60405180910390a173ffffffffffffffffffffffffffffffffffffffff83166104c057604051339086156108fc029087906000818181858888f193505050501580156104ba573d6000803e3d6000fd5b506104e1565b6104e173ffffffffffffffffffffffffffffffffffffffff84163387610f15565b505050505050565b73ffffffffffffffffffffffffffffffffffffffff83166105725760405162461bcd60e51b815260206004820152602360248201527f52656365697665722063616e6e6f7420626520746865207a65726f206164647260448201527f65737300000000000000000000000000000000000000000000000000000000006064820152608401610219565b600034116105e85760405162461bcd60e51b815260206004820152602560248201527f5061796d656e7420616d6f756e74206d7573742062652067726561746572207460448201527f68616e20300000000000000000000000000000000000000000000000000000006064820152608401610219565b600080858152602081905260409020547c0100000000000000000000000000000000000000000000000000000000900460ff16600381111561062c5761062c61111d565b146106795760405162461bcd60e51b815260206004820152601f60248201527f455448207061796d656e7420616c726561647920696e697469616c697a6564006044820152606401610219565b6040516bffffffffffffffffffffffff19606085811b8216602084015233901b81166034830152831660488201526000605c82018190523460708301529060039060900160408051601f19818403018152908290526106d7916112dc565b602060405180830381855afa1580156106f4573d6000803e3d6000fd5b5050604080518051606080830184521b6bffffffffffffffffffffffff198116825267ffffffffffffffff861660208301529350915081016001905260008681526020818152604091829020835181549285015167ffffffffffffffff1674010000000000000000000000000000000000000000027fffffffff0000000000000000000000000000000000000000000000000000000090931660609190911c179190911780825591830151909182907fffffff00ffffffffffffffffffffffffffffffffffffffffffffffffffffffff167c01000000000000000000000000000000000000000000000000000000008360038111156107f5576107f561111d565b0217905550506040518681527fccc9c05183599bd3135da606eaaf535daffe256e9de33c048014cffcccd4ad57915060200160405180910390a15050505050565b60016000868152602081905260409020547c0100000000000000000000000000000000000000000000000000000000900460ff16600381111561087b5761087b61111d565b146108ee5760405162461bcd60e51b815260206004820152602a60248201527f496e76616c6964207061796d656e742073746174652e204d757374206265205060448201527f61796d656e7453656e74000000000000000000000000000000000000000000006064820152608401610219565b6040516bffffffffffffffffffffffff19606083811b8216602084015233811b82166034840152818616604884015284901b16605c8201526070810185905260009060039060900160408051601f1981840301815290829052610950916112dc565b602060405180830381855afa15801561096d573d6000803e3d6000fd5b50506040805151600089815260208190529190912054606091821b9350901b6bffffffffffffffffffffffff199081169083161490506109ef5760405162461bcd60e51b815260206004820152601360248201527f496e76616c6964207061796d656e7448617368000000000000000000000000006044820152606401610219565b60008681526020819052604090205474010000000000000000000000000000000000000000900467ffffffffffffffff16421015610a955760405162461bcd60e51b815260206004820152603160248201527f43757272656e742074696d657374616d70206469646e2774206578636565642060448201527f7061796d656e74206c6f636b2074696d650000000000000000000000000000006064820152608401610219565b6000868152602081815260409182902080547fffffff00ffffffffffffffffffffffffffffffffffffffffffffffffffffffff167c030000000000000000000000000000000000000000000000000000000017905590518781527f1797d500133f8e427eb9da9523aa4a25cb40f50ebc7dbda3c7c81778973f35ba910161046a565b73ffffffffffffffffffffffffffffffffffffffff8316610ba05760405162461bcd60e51b815260206004820152602360248201527f52656365697665722063616e6e6f7420626520746865207a65726f206164647260448201527f65737300000000000000000000000000000000000000000000000000000000006064820152608401610219565b73ffffffffffffffffffffffffffffffffffffffff8416610c035760405162461bcd60e51b815260206004820152601c60248201527f546f6b656e20616464726573732063616e6e6f74206265207a65726f000000006044820152606401610219565b60008511610c795760405162461bcd60e51b815260206004820152602560248201527f5061796d656e7420616d6f756e74206d7573742062652067726561746572207460448201527f68616e20300000000000000000000000000000000000000000000000000000006064820152608401610219565b600080878152602081905260409020547c0100000000000000000000000000000000000000000000000000000000900460ff166003811115610cbd57610cbd61111d565b14610d305760405162461bcd60e51b815260206004820152602160248201527f4552433230207061796d656e7420616c726561647920696e697469616c697a6560448201527f64000000000000000000000000000000000000000000000000000000000000006064820152608401610219565b6040516bffffffffffffffffffffffff19606085811b8216602084015233811b82166034840152818516604884015286901b16605c8201526070810186905260009060039060900160408051601f1981840301815290829052610d92916112dc565b602060405180830381855afa158015610daf573d6000803e3d6000fd5b5050604080518051606080830184521b6bffffffffffffffffffffffff198116825267ffffffffffffffff861660208301529350915081016001905260008881526020818152604091829020835181549285015167ffffffffffffffff1674010000000000000000000000000000000000000000027fffffffff0000000000000000000000000000000000000000000000000000000090931660609190911c179190911780825591830151909182907fffffff00ffffffffffffffffffffffffffffffffffffffffffffffffffffffff167c0100000000000000000000000000000000000000000000000000000000836003811115610eb057610eb061111d565b0217905550506040518881527fccc9c05183599bd3135da606eaaf535daffe256e9de33c048014cffcccd4ad57915060200160405180910390a1610f0c73ffffffffffffffffffffffffffffffffffffffff8616333089610f9b565b50505050505050565b60405173ffffffffffffffffffffffffffffffffffffffff838116602483015260448201839052610f9691859182169063a9059cbb906064015b604051602081830303815290604052915060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff8381831617835250505050610fe7565b505050565b60405173ffffffffffffffffffffffffffffffffffffffff8481166024830152838116604483015260648201839052610fe19186918216906323b872dd90608401610f4f565b50505050565b600080602060008451602086016000885af18061100a576040513d6000823e3d81fd5b50506000513d9150811561102257806001141561103c565b73ffffffffffffffffffffffffffffffffffffffff84163b155b15610fe1576040517f5274afe700000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff85166004820152602401610219565b803573ffffffffffffffffffffffffffffffffffffffff811681146110af57600080fd5b919050565b600080600080600060a086880312156110cc57600080fd5b8535945060208601359350604086013592506110ea6060870161108b565b91506110f86080870161108b565b90509295509295909350565b60006020828403121561111657600080fd5b5035919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052602160045260246000fd5b6bffffffffffffffffffffffff198416815267ffffffffffffffff8316602082015260608101600483106111a9577f4e487b7100000000000000000000000000000000000000000000000000000000600052602160045260246000fd5b826040830152949350505050565b80356bffffffffffffffffffffffff19811681146110af57600080fd5b803567ffffffffffffffff811681146110af57600080fd5b6000806000806080858703121561120257600080fd5b843593506112126020860161108b565b9250611220604086016111b7565b915061122e606086016111d4565b905092959194509250565b600080600080600060a0868803121561125157600080fd5b8535945060208601359350611268604087016111b7565b92506110ea6060870161108b565b60008060008060008060c0878903121561128f57600080fd5b86359550602087013594506112a66040880161108b565b93506112b46060880161108b565b92506112c2608088016111b7565b91506112d060a088016111d4565b90509295509295509295565b6000825160005b818110156112fd57602081860181015185830152016112e3565b506000920191825250919050565b60006020828403121561131d57600080fd5b505191905056fea264697066735822122038c75c7971f79661b8ba328d5da539577df3351edcffdd121284740a6c16deb664736f6c63430008210033 \ No newline at end of file diff --git a/mm2src/mm2_test_helpers/contract_bytes/usdt_contract_bytes b/mm2src/mm2_test_helpers/contract_bytes/usdt_contract_bytes new file mode 100644 index 0000000000..5b00734326 --- /dev/null +++ b/mm2src/mm2_test_helpers/contract_bytes/usdt_contract_bytes @@ -0,0 +1 @@ +606060405260008060146101000a81548160ff0219169083151502179055506000600355600060045534156200003457600080fd5b60405162002d7c38038062002d7c83398101604052808051906020019091908051820191906020018051820191906020018051906020019091905050336000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550836001819055508260079080519060200190620000cf9291906200017a565b508160089080519060200190620000e89291906200017a565b508060098190555083600260008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055506000600a60146101000a81548160ff0219169083151502179055505050505062000229565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282601f10620001bd57805160ff1916838001178555620001ee565b82800160010185558215620001ee579182015b82811115620001ed578251825591602001919060010190620001d0565b5b509050620001fd919062000201565b5090565b6200022691905b808211156200022257600081600090555060010162000208565b5090565b90565b612b4380620002396000396000f300606060405260043610610196576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806306fdde031461019b5780630753c30c14610229578063095ea7b3146102625780630e136b19146102a45780630ecb93c0146102d157806318160ddd1461030a57806323b872dd1461033357806326976e3f1461039457806327e235e3146103e9578063313ce56714610436578063353907141461045f5780633eaaf86b146104885780633f4ba83a146104b157806359bf1abe146104c65780635c658165146105175780635c975abb1461058357806370a08231146105b05780638456cb59146105fd578063893d20e8146106125780638da5cb5b1461066757806395d89b41146106bc578063a9059cbb1461074a578063c0324c771461078c578063cc872b66146107b8578063db006a75146107db578063dd62ed3e146107fe578063dd644f721461086a578063e47d606014610893578063e4997dc5146108e4578063e5b5019a1461091d578063f2fde38b14610946578063f3bdc2281461097f575b600080fd5b34156101a657600080fd5b6101ae6109b8565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156101ee5780820151818401526020810190506101d3565b50505050905090810190601f16801561021b5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b341561023457600080fd5b610260600480803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050610a56565b005b341561026d57600080fd5b6102a2600480803573ffffffffffffffffffffffffffffffffffffffff16906020019091908035906020019091905050610b73565b005b34156102af57600080fd5b6102b7610cc1565b604051808215151515815260200191505060405180910390f35b34156102dc57600080fd5b610308600480803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050610cd4565b005b341561031557600080fd5b61031d610ded565b6040518082815260200191505060405180910390f35b341561033e57600080fd5b610392600480803573ffffffffffffffffffffffffffffffffffffffff1690602001909190803573ffffffffffffffffffffffffffffffffffffffff16906020019091908035906020019091905050610ebd565b005b341561039f57600080fd5b6103a761109d565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b34156103f457600080fd5b610420600480803573ffffffffffffffffffffffffffffffffffffffff169060200190919050506110c3565b6040518082815260200191505060405180910390f35b341561044157600080fd5b6104496110db565b6040518082815260200191505060405180910390f35b341561046a57600080fd5b6104726110e1565b6040518082815260200191505060405180910390f35b341561049357600080fd5b61049b6110e7565b6040518082815260200191505060405180910390f35b34156104bc57600080fd5b6104c46110ed565b005b34156104d157600080fd5b6104fd600480803573ffffffffffffffffffffffffffffffffffffffff169060200190919050506111ab565b604051808215151515815260200191505060405180910390f35b341561052257600080fd5b61056d600480803573ffffffffffffffffffffffffffffffffffffffff1690602001909190803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050611201565b6040518082815260200191505060405180910390f35b341561058e57600080fd5b610596611226565b604051808215151515815260200191505060405180910390f35b34156105bb57600080fd5b6105e7600480803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050611239565b6040518082815260200191505060405180910390f35b341561060857600080fd5b610610611348565b005b341561061d57600080fd5b610625611408565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b341561067257600080fd5b61067a611431565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b34156106c757600080fd5b6106cf611456565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561070f5780820151818401526020810190506106f4565b50505050905090810190601f16801561073c5780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b341561075557600080fd5b61078a600480803573ffffffffffffffffffffffffffffffffffffffff169060200190919080359060200190919050506114f4565b005b341561079757600080fd5b6107b6600480803590602001909190803590602001909190505061169e565b005b34156107c357600080fd5b6107d96004808035906020019091905050611783565b005b34156107e657600080fd5b6107fc600480803590602001909190505061197a565b005b341561080957600080fd5b610854600480803573ffffffffffffffffffffffffffffffffffffffff1690602001909190803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050611b0d565b6040518082815260200191505060405180910390f35b341561087557600080fd5b61087d611c52565b6040518082815260200191505060405180910390f35b341561089e57600080fd5b6108ca600480803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050611c58565b604051808215151515815260200191505060405180910390f35b34156108ef57600080fd5b61091b600480803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050611c78565b005b341561092857600080fd5b610930611d91565b6040518082815260200191505060405180910390f35b341561095157600080fd5b61097d600480803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050611db5565b005b341561098a57600080fd5b6109b6600480803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050611e8a565b005b60078054600181600116156101000203166002900480601f016020809104026020016040519081016040528092919081815260200182805460018160011615610100020316600290048015610a4e5780601f10610a2357610100808354040283529160200191610a4e565b820191906000526020600020905b815481529060010190602001808311610a3157829003601f168201915b505050505081565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16141515610ab157600080fd5b6001600a60146101000a81548160ff02191690831515021790555080600a60006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507fcc358699805e9a8b7f77b522628c7cb9abd07d9efb86b6fb616af1609036a99e81604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a150565b604060048101600036905010151515610b8b57600080fd5b600a60149054906101000a900460ff1615610cb157600a60009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1663aee92d333385856040518463ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401808473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018281526020019350505050600060405180830381600087803b1515610c9857600080fd5b6102c65a03f11515610ca957600080fd5b505050610cbc565b610cbb838361200e565b5b505050565b600a60149054906101000a900460ff1681565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16141515610d2f57600080fd5b6001600660008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548160ff0219169083151502179055507f42e160154868087d6bfdc0ca23d96a1c1cfa32f1b72ba9ba27b69b98a0d819dc81604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a150565b6000600a60149054906101000a900460ff1615610eb457600a60009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff166318160ddd6000604051602001526040518163ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401602060405180830381600087803b1515610e9257600080fd5b6102c65a03f11515610ea357600080fd5b505050604051805190509050610eba565b60015490505b90565b600060149054906101000a900460ff16151515610ed957600080fd5b600660008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900460ff16151515610f3257600080fd5b600a60149054906101000a900460ff161561108c57600a60009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16638b477adb338585856040518563ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401808573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001828152602001945050505050600060405180830381600087803b151561107357600080fd5b6102c65a03f1151561108457600080fd5b505050611098565b6110978383836121ab565b5b505050565b600a60009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b60026020528060005260406000206000915090505481565b60095481565b60045481565b60015481565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614151561114857600080fd5b600060149054906101000a900460ff16151561116357600080fd5b60008060146101000a81548160ff0219169083151502179055507f7805862f689e2f13df9f062ff482ad3ad112aca9e0847911ed832e158c525b3360405160405180910390a1565b6000600660008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900460ff169050919050565b6005602052816000526040600020602052806000526040600020600091509150505481565b600060149054906101000a900460ff1681565b6000600a60149054906101000a900460ff161561133757600a60009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff166370a08231836000604051602001526040518263ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001915050602060405180830381600087803b151561131557600080fd5b6102c65a03f1151561132657600080fd5b505050604051805190509050611343565b61134082612652565b90505b919050565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415156113a357600080fd5b600060149054906101000a900460ff161515156113bf57600080fd5b6001600060146101000a81548160ff0219169083151502179055507f6985a02210a168e66602d3235cb6db0e70f92b3ba4d376a33c0f3d9434bff62560405160405180910390a1565b60008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b60088054600181600116156101000203166002900480601f0160208091040260200160405190810160405280929190818152602001828054600181600116156101000203166002900480156114ec5780601f106114c1576101008083540402835291602001916114ec565b820191906000526020600020905b8154815290600101906020018083116114cf57829003601f168201915b505050505081565b600060149054906101000a900460ff1615151561151057600080fd5b600660003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900460ff1615151561156957600080fd5b600a60149054906101000a900460ff161561168f57600a60009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16636e18980a3384846040518463ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401808473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018281526020019350505050600060405180830381600087803b151561167657600080fd5b6102c65a03f1151561168757600080fd5b50505061169a565b611699828261269b565b5b5050565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415156116f957600080fd5b60148210151561170857600080fd5b60328110151561171757600080fd5b81600381905550611736600954600a0a82612a0390919063ffffffff16565b6004819055507fb044a1e409eac5c48e5af22d4af52670dd1a99059537a78b31b48c6500a6354e600354600454604051808381526020018281526020019250505060405180910390a15050565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415156117de57600080fd5b60015481600154011115156117f257600080fd5b600260008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205481600260008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054011115156118c257600080fd5b80600260008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008282540192505081905550806001600082825401925050819055507fcb8241adb0c3fdb35b70c24ce35c5eb0c17af7431c99f827d44a445ca624176a816040518082815260200191505060405180910390a150565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415156119d557600080fd5b80600154101515156119e657600080fd5b80600260008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205410151515611a5557600080fd5b8060016000828254039250508190555080600260008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600082825403925050819055507f702d5967f45f6513a38ffc42d6ba9bf230bd40e8f53b16363c7eb4fd2deb9a44816040518082815260200191505060405180910390a150565b6000600a60149054906101000a900460ff1615611c3f57600a60009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1663dd62ed3e84846000604051602001526040518363ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401808373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200192505050602060405180830381600087803b1515611c1d57600080fd5b6102c65a03f11515611c2e57600080fd5b505050604051805190509050611c4c565b611c498383612a3e565b90505b92915050565b60035481565b60066020528060005260406000206000915054906101000a900460ff1681565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16141515611cd357600080fd5b6000600660008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548160ff0219169083151502179055507fd7e9ec6e6ecd65492dce6bf513cd6867560d49544421d0783ddf06e76c24470c81604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390a150565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff81565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16141515611e1057600080fd5b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16141515611e8757806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505b50565b60008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16141515611ee757600080fd5b600660008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900460ff161515611f3f57600080fd5b611f4882611239565b90506000600260008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002081905550806001600082825403925050819055507f61e6e66b0d6339b2980aecc6ccc0039736791f0ccde9ed512e789a7fbdd698c68282604051808373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020018281526020019250505060405180910390a15050565b60406004810160003690501015151561202657600080fd5b600082141580156120b457506000600560003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205414155b1515156120c057600080fd5b81600560003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055508273ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925846040518082815260200191505060405180910390a3505050565b60008060006060600481016000369050101515156121c857600080fd5b600560008873ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054935061227061271061226260035488612a0390919063ffffffff16565b612ac590919063ffffffff16565b92506004548311156122825760045492505b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff84101561233e576122bd8585612ae090919063ffffffff16565b600560008973ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055505b6123518386612ae090919063ffffffff16565b91506123a585600260008a73ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054612ae090919063ffffffff16565b600260008973ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000208190555061243a82600260008973ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054612af990919063ffffffff16565b600260008873ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000208190555060008311156125e4576124f983600260008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054612af990919063ffffffff16565b600260008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055506000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168773ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef856040518082815260200191505060405180910390a35b8573ffffffffffffffffffffffffffffffffffffffff168773ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef846040518082815260200191505060405180910390a350505050505050565b6000600260008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020549050919050565b6000806040600481016000369050101515156126b657600080fd5b6126df6127106126d160035487612a0390919063ffffffff16565b612ac590919063ffffffff16565b92506004548311156126f15760045492505b6127048385612ae090919063ffffffff16565b915061275884600260003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054612ae090919063ffffffff16565b600260003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055506127ed82600260008873ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054612af990919063ffffffff16565b600260008773ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055506000831115612997576128ac83600260008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054612af990919063ffffffff16565b600260008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055506000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef856040518082815260200191505060405180910390a35b8473ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef846040518082815260200191505060405180910390a35050505050565b6000806000841415612a185760009150612a37565b8284029050828482811515612a2957fe5b04141515612a3357fe5b8091505b5092915050565b6000600560008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054905092915050565b6000808284811515612ad357fe5b0490508091505092915050565b6000828211151515612aee57fe5b818303905092915050565b6000808284019050838110151515612b0d57fe5b80915050929150505600a165627a7a72305820645ee12d73db47fd78ba77fa1f824c3c8f9184061b3b10386beb4dc9236abb280029000000000000000000000000000000000000000000000000000000174876e800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a546574686572205553440000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000045553445400000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/mm2src/mm2_test_helpers/dummy_files/usdt_abi.json b/mm2src/mm2_test_helpers/dummy_files/usdt_abi.json new file mode 100644 index 0000000000..b59b944a26 --- /dev/null +++ b/mm2src/mm2_test_helpers/dummy_files/usdt_abi.json @@ -0,0 +1,671 @@ +[ + { + "constant": true, + "inputs": [], + "name": "name", + "outputs": [ + { + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_upgradedAddress", + "type": "address" + } + ], + "name": "deprecate", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_spender", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "deprecated", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_evilUser", + "type": "address" + } + ], + "name": "addBlackList", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_from", + "type": "address" + }, + { + "name": "_to", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "upgradedAddress", + "outputs": [ + { + "name": "", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "", + "type": "address" + } + ], + "name": "balances", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "decimals", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "maximumFee", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "_totalSupply", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [], + "name": "unpause", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_maker", + "type": "address" + } + ], + "name": "getBlackListStatus", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "", + "type": "address" + }, + { + "name": "", + "type": "address" + } + ], + "name": "allowed", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "paused", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "who", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [], + "name": "pause", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getOwner", + "outputs": [ + { + "name": "", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "owner", + "outputs": [ + { + "name": "", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "symbol", + "outputs": [ + { + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_to", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "newBasisPoints", + "type": "uint256" + }, + { + "name": "newMaxFee", + "type": "uint256" + } + ], + "name": "setParams", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "amount", + "type": "uint256" + } + ], + "name": "issue", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "amount", + "type": "uint256" + } + ], + "name": "redeem", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_owner", + "type": "address" + }, + { + "name": "_spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "name": "remaining", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "basisPointsRate", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "", + "type": "address" + } + ], + "name": "isBlackListed", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_clearedUser", + "type": "address" + } + ], + "name": "removeBlackList", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "MAX_UINT", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_blackListedUser", + "type": "address" + } + ], + "name": "destroyBlackFunds", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "name": "_initialSupply", + "type": "uint256" + }, + { + "name": "_name", + "type": "string" + }, + { + "name": "_symbol", + "type": "string" + }, + { + "name": "_decimals", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "name": "amount", + "type": "uint256" + } + ], + "name": "Issue", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "name": "amount", + "type": "uint256" + } + ], + "name": "Redeem", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "name": "newAddress", + "type": "address" + } + ], + "name": "Deprecate", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "name": "feeBasisPoints", + "type": "uint256" + }, + { + "indexed": false, + "name": "maxFee", + "type": "uint256" + } + ], + "name": "Params", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "name": "_blackListedUser", + "type": "address" + }, + { + "indexed": false, + "name": "_balance", + "type": "uint256" + } + ], + "name": "DestroyedBlackFunds", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "name": "_user", + "type": "address" + } + ], + "name": "AddedBlackList", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "name": "_user", + "type": "address" + } + ], + "name": "RemovedBlackList", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "from", + "type": "address" + }, + { + "indexed": true, + "name": "to", + "type": "address" + }, + { + "indexed": false, + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "anonymous": false, + "inputs": [], + "name": "Pause", + "type": "event" + }, + { + "anonymous": false, + "inputs": [], + "name": "Unpause", + "type": "event" + } +] diff --git a/mm2src/mm2_test_helpers/src/electrums.rs b/mm2src/mm2_test_helpers/src/electrums.rs index a410daf1c2..e4fda709f3 100644 --- a/mm2src/mm2_test_helpers/src/electrums.rs +++ b/mm2src/mm2_test_helpers/src/electrums.rs @@ -73,7 +73,9 @@ pub fn tbtc_electrums() -> Vec { } #[cfg(target_arch = "wasm32")] -pub fn tqtum_electrums() -> Vec { vec![json!({ "url": "electrum3.cipig.net:30071", "protocol": "WSS" })] } +pub fn tqtum_electrums() -> Vec { + vec![json!({ "url": "electrum3.cipig.net:30071", "protocol": "WSS" })] +} #[cfg(not(target_arch = "wasm32"))] pub fn tqtum_electrums() -> Vec { diff --git a/mm2src/mm2_test_helpers/src/for_tests.rs b/mm2src/mm2_test_helpers/src/for_tests.rs index ca48513a21..193b5df28c 100644 --- a/mm2src/mm2_test_helpers/src/for_tests.rs +++ b/mm2src/mm2_test_helpers/src/for_tests.rs @@ -168,6 +168,22 @@ pub const TAKER_ERROR_EVENTS: [&str; 17] = [ "TakerPaymentRefundFinished", ]; +/// Legacy DEX fee public key - used in tests to validate historical transactions +/// that were sent to the old fee address before the fee update. +pub const DEX_FEE_ADDR_PUBKEY_LEGACY: &str = "03bc2c7ba671bae4a6fc835244c9762b41647b9827d4780a89a949b984a8ddcc06"; +/// Legacy DEX burn public key - used in tests to validate historical transactions +/// that were sent to the old burn address before the fee update. +pub const DEX_BURN_ADDR_PUBKEY_LEGACY: &str = "0369aa10c061cd9e085f4adb7399375ba001b54136145cb748eb4c48657be13153"; + +lazy_static! { + /// Legacy DEX fee raw pubkey bytes for test fixtures + pub static ref DEX_FEE_ADDR_RAW_PUBKEY_LEGACY: Vec = + hex::decode(DEX_FEE_ADDR_PUBKEY_LEGACY).expect("DEX_FEE_ADDR_PUBKEY_LEGACY is expected to be a hexadecimal string"); + /// Legacy DEX burn raw pubkey bytes for test fixtures + pub static ref DEX_BURN_ADDR_RAW_PUBKEY_LEGACY: Vec = + hex::decode(DEX_BURN_ADDR_PUBKEY_LEGACY).expect("DEX_BURN_ADDR_PUBKEY_LEGACY is expected to be a hexadecimal string"); +} + pub const RICK: &str = "RICK"; pub const RICK_ELECTRUM_ADDRS: &[&str] = &[ "electrum1.cipig.net:10017", @@ -263,6 +279,33 @@ pub const ETH_SEPOLIA_TOKEN_CONTRACT: &str = "0x09d0d71FBC00D7CCF9CFf132f5E6825C pub const BCHD_TESTNET_URLS: &[&str] = &["https://bchd-testnet.greyh.at:18335"]; +/// TRON Nile testnet RPC nodes. +/// Nile is recommended over Shasta for more flexibility with RPC providers. +pub const TRON_NILE_NODES: &[&str] = &["https://api.nileex.io", "https://nile.trongrid.io"]; + +/// Known TRON testnet address that is always "activated" (zero address equivalent). +/// This is the TRON network foundation address on testnet that has activity. +/// T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb is the genesis address. +pub const TRON_TESTNET_KNOWN_ADDRESS: &str = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"; + +/// TRX ticker constant for tests. +pub const TRX_TICKER: &str = "TRX"; + +/// TRC20 test token contract on TRON Nile testnet. +/// This is a test USDT contract deployed on Nile for testing purposes. +/// Contract: TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf (Nile test USDT) +pub const TRON_NILE_TRC20_USDT_CONTRACT: &str = "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"; + +/// TRC20 test token ticker for tests. +pub const TRON_NILE_TRC20_USDT_TICKER: &str = "USDT-TRC20-NILE"; + +/// Mnemonic used by TRON withdraw integration tests (Nile). +/// Index 0: TDcxD6E5wTzvqCJd4RfkGfw9NkCBdvYcV9 (50 TRX + 10 USDT) +/// Index 1: TW9RqU6bTJnM4quyRbvTwm3xfSHgk718qU (20 TRX + 5 USDT) +/// Index 2: TVK3ruiuNxN4sRJtSThDW7PGHrwYPYQ1UC (unfunded) +pub const TRON_WITHDRAW_TEST_PASSPHRASE: &str = + "inject night leg month assume task power city until switch movie develop"; + #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum TypedRpcResponse { @@ -778,10 +821,10 @@ pub fn btc_with_sync_starting_header() -> Json { }, "spv_conf": { "starting_block_header": { - "height": 764064, - "hash": "00000000000000000006da48b920343944908861fa05b28824922d9e60aaa94d", - "bits": 386375189, - "time": 1668986059, + "height": 872928, + "hash": "00000000000000000001dc2f171d19c36ad8afb972287230900b2a352184402a", + "bits": 386053475, + "time": 1733153640, }, "max_stored_block_headers": 3000, "validation_params": { @@ -942,6 +985,26 @@ pub fn erc20_dev_conf(contract_address: &str) -> Json { }) } +/// USDT token configuration used for dockerized Geth dev node. +/// Uses 6 decimals like real mainnet USDT. +pub fn usdt_dev_conf(contract_address: &str) -> Json { + json!({ + "coin": "USDT", + "name": "usdt", + "mm2": 1, + "decimals": 6, + "derivation_path": "m/44'/60'", + "protocol": { + "type": "ERC20", + "protocol_data": { + "platform": "ETH", + "contract_address": contract_address, + } + }, + "max_eth_tx_type": 2 + }) +} + /// ERC20 token configuration used for dockerized tests on Sepolia pub fn sepolia_erc20_dev_conf(contract_address: &str) -> Json { let mut conf = erc20_dev_conf(contract_address); @@ -1002,6 +1065,48 @@ pub fn eth_sepolia_trezor_firmware_compat_conf() -> Json { }) } +/// TRX coin config for MarketMakerIt tests (Nile testnet). +/// Uses TRON's SLIP-44 coin type 195 for HD wallet derivation. +pub fn trx_conf() -> Json { + json!({ + "coin": "TRX", + "name": "tron", + "fname": "TRON", + "mm2": 1, + "wallet_only": true, + "decimals": 6, + "avg_blocktime": 3, + "required_confirmations": 1, + "derivation_path": "m/44'/195'", + "protocol": { + "type": "TRX", + "protocol_data": { + "network": "Nile" + } + } + }) +} + +/// TRC20 USDT test token config for Nile testnet. +/// Uses the same derivation path as TRX since tokens share the platform's addresses. +pub fn trc20_usdt_nile_conf() -> Json { + json!({ + "coin": TRON_NILE_TRC20_USDT_TICKER, + "name": "usdt_trc20_nile", + "fname": "USDT (TRC20 Nile)", + "mm2": 1, + "wallet_only": true, + "derivation_path": "m/44'/195'", + "protocol": { + "type": "TRC20", + "protocol_data": { + "platform": "TRX", + "contract_address": TRON_NILE_TRC20_USDT_CONTRACT + } + } + }) +} + pub fn eth_jst_testnet_conf() -> Json { json!({ "coin": "JST", @@ -2714,7 +2819,10 @@ pub async fn wait_check_stats_swap_status(mm: &MarketMakerIt, uuid: &str, timeou if !response.0.is_success() { Timer::sleep(1.).await; if get_utc_timestamp() > wait_until { - panic!("Timed out waiting for swap stats status uuid={}, latest status={}", uuid, response.1); + panic!( + "Timed out waiting for swap stats status uuid={}, latest status={}", + uuid, response.1 + ); } continue; } @@ -3525,7 +3633,7 @@ pub async fn init_utxo_electrum( coin: &str, servers: Vec, path_to_address: Option, - priv_key_policy: Option<&str>, + priv_key_policy: Option, ) -> Json { let mut activation_params = json!({ "mode": { @@ -3536,7 +3644,7 @@ pub async fn init_utxo_electrum( } }); if let Some(priv_key_policy) = priv_key_policy { - activation_params["priv_key_policy"] = priv_key_policy.into(); + activation_params["priv_key_policy"] = priv_key_policy; } if let Some(path_to_address) = path_to_address { activation_params["path_to_address"] = json!(path_to_address); @@ -3589,7 +3697,7 @@ pub async fn enable_utxo_v2_electrum( servers: Vec, path_to_address: Option, timeout: u64, - priv_key_policy: Option<&str>, + priv_key_policy: Option, ) -> UtxoStandardActivationResult { let init = init_utxo_electrum(mm, coin, servers, path_to_address, priv_key_policy).await; let init: RpcV2Response = json::from_value(init).unwrap(); @@ -3615,26 +3723,30 @@ async fn task_enable_eth_with_tokens_init( mm: &MarketMakerIt, platform_coin: &str, tokens: &[&str], - swap_contract_address: &str, + swap_contract_address: Option<&str>, nodes: &[&str], path_to_address: Option, ) -> Json { let erc20_tokens_requests: Vec<_> = tokens.iter().map(|ticker| json!({ "ticker": ticker })).collect(); let nodes: Vec<_> = nodes.iter().map(|url| json!({ "url": url })).collect(); + let mut params = json!({ + "ticker": platform_coin, + "nodes": nodes, + "tx_history": true, + "erc20_tokens_requests": erc20_tokens_requests, + "path_to_address": path_to_address.unwrap_or_default(), + }); + if let Some(addr) = swap_contract_address { + params["swap_contract_address"] = json!(addr); + } + let response = mm .rpc(&json!({ - "userpass": mm.userpass, - "method": "task::enable_eth::init", - "mmrpc": "2.0", - "params": { - "ticker": platform_coin, - "swap_contract_address": swap_contract_address, - "nodes": nodes, - "tx_history": true, - "erc20_tokens_requests": erc20_tokens_requests, - "path_to_address": path_to_address.unwrap_or_default(), - } + "userpass": mm.userpass, + "method": "task::enable_eth::init", + "mmrpc": "2.0", + "params": params })) .await .unwrap(); @@ -3672,12 +3784,14 @@ pub async fn task_enable_eth_with_tokens( mm: &MarketMakerIt, platform_coin: &str, tokens: &[&str], - swap_contract_address: &str, + swap_contract_address: Option<&str>, nodes: &[&str], timeout: u64, path_to_address: Option, ) -> EthWithTokensActivationResult { - let init = task_enable_eth_with_tokens_init(mm, platform_coin, tokens, swap_contract_address, nodes, path_to_address).await; + let init = + task_enable_eth_with_tokens_init(mm, platform_coin, tokens, swap_contract_address, nodes, path_to_address) + .await; let init: RpcV2Response = json::from_value(init).unwrap(); let timeout = wait_until_ms(timeout * 1000); @@ -3696,6 +3810,106 @@ pub async fn task_enable_eth_with_tokens( } } +/// Immediate TRX activation helper with optional TRC20 tokens. +pub async fn enable_trx_with_tokens(mm: &MarketMakerIt, nodes: &[&str], tokens: &[&str]) -> Json { + let nodes: Vec<_> = nodes.iter().map(|url| json!({ "url": url })).collect(); + let erc20_tokens_requests: Vec<_> = tokens.iter().map(|ticker| json!({ "ticker": ticker })).collect(); + let enable = mm + .rpc(&json!({ + "userpass": mm.userpass, + "method": "enable_eth_with_tokens", + "mmrpc": "2.0", + "params": { + "ticker": "TRX", + "mm2": 1, + "nodes": nodes, + "erc20_tokens_requests": erc20_tokens_requests + } + })) + .await + .unwrap(); + assert_eq!( + enable.0, + StatusCode::OK, + "'enable_eth_with_tokens' for TRX failed: {}", + enable.1 + ); + json::from_str(&enable.1).unwrap() +} + +/// Immediate TRX activation helper using the enable RPC (no tokens). +pub async fn enable_trx(mm: &MarketMakerIt, nodes: &[&str]) -> Json { + enable_trx_with_tokens(mm, nodes, &[]).await +} + +/// TRX task init helper with optional TRC20 tokens (typed). +/// Internally calls the shared `task::enable_eth::init` endpoint. +pub async fn task_enable_trx_with_tokens_init( + mm: &MarketMakerIt, + nodes: &[&str], + tokens: &[&str], + path_to_address: Option, +) -> RpcV2Response { + let init = task_enable_eth_with_tokens_init(mm, "TRX", tokens, None, nodes, path_to_address).await; + json::from_value(init).unwrap() +} + +/// TRX task init helper (typed, no tokens). +/// Internally calls the shared `task::enable_eth::init` endpoint. +pub async fn task_enable_trx_init( + mm: &MarketMakerIt, + nodes: &[&str], + path_to_address: Option, +) -> RpcV2Response { + task_enable_trx_with_tokens_init(mm, nodes, &[], path_to_address).await +} + +/// TRX task status helper (typed). +/// Internally calls the shared `task::enable_eth::status` endpoint. +pub async fn task_enable_trx_status(mm: &MarketMakerIt, task_id: u64) -> RpcV2Response { + let status = task_eth_with_tokens_status(mm, task_id).await; + json::from_value(status).unwrap() +} + +/// Task-based TRX activation helper with optional TRC20 tokens. +pub async fn task_enable_trx_with_tokens( + mm: &MarketMakerIt, + nodes: &[&str], + tokens: &[&str], + timeout_sec: u64, + path_to_address: Option, +) -> Result { + let init = task_enable_trx_with_tokens_init(mm, nodes, tokens, path_to_address).await; + let timeout_at = wait_until_ms(timeout_sec * 1000); + + loop { + if now_ms() > timeout_at { + return Err(TaskEnableError::Timeout { + ticker: "TRX".to_string(), + timeout_sec, + }); + } + + let status = task_enable_trx_status(mm, init.result.task_id).await; + match status.result { + InitEthWithTokensStatus::Ok(result) => return Ok(result), + InitEthWithTokensStatus::Error(e) => return Err(TaskEnableError::RpcError(e)), + InitEthWithTokensStatus::UserActionRequired(e) => return Err(TaskEnableError::RpcError(e)), + InitEthWithTokensStatus::InProgress(_) => Timer::sleep(1.).await, + } + } +} + +/// Task-based TRX activation helper (no tokens). +pub async fn task_enable_trx( + mm: &MarketMakerIt, + nodes: &[&str], + timeout_sec: u64, + path_to_address: Option, +) -> Result { + task_enable_trx_with_tokens(mm, nodes, &[], timeout_sec, path_to_address).await +} + async fn init_erc20_token( mm: &MarketMakerIt, ticker: &str, @@ -4126,16 +4340,21 @@ pub async fn account_balance( coin: &str, account_index: u32, chain: Bip44Chain, + limit: Option, ) -> HDAccountBalanceResponse { + let mut params = json!({ + "coin": coin, + "account_index": account_index, + "chain": chain + }); + if let Some(limit) = limit { + params["limit"] = json!(limit); + } let request = json!({ "userpass": mm.userpass, "method": "account_balance", "mmrpc": "2.0", - "params": { - "coin": coin, - "account_index": account_index, - "chain": chain - } + "params": params }); let request = mm.rpc(&request).await.unwrap(); @@ -4311,3 +4530,44 @@ pub async fn active_swaps(mm: &MarketMakerIt) -> ActiveSwapsResponse { assert_eq!(response.0, StatusCode::OK, "'active_swaps' failed: {}", response.1); json::from_str(&response.1).unwrap() } + +pub async fn new_walletconnect_connection(mm: &MarketMakerIt, params: Json) -> CreateConnectionResponse { + let request = json!({ + "method": "wc_new_connection", + "userpass": mm.userpass, + "mmrpc": "2.0", + "params": params, + }); + let response = mm.rpc(&request).await.unwrap(); + assert_eq!(response.0, StatusCode::OK, "'wc_new_connection' failed: {}", response.1); + log!("wc_new_connection response {}", response.1); + let response: RpcV2Response = json::from_str(&response.1).unwrap(); + response.result +} + +pub async fn wait_for_walletconnect_session(mm: &MarketMakerIt, pairing_topic: &str, timeout: u64) -> String { + let timeout = wait_until_ms(timeout * 1000); + loop { + if now_ms() > timeout { + panic!("WalletConnect session not established in {} seconds", timeout / 1000); + } + + let request = json!({ + "userpass": mm.userpass, + "method": "wc_get_session", + "mmrpc": "2.0", + "params": { + "topic": pairing_topic, + "with_pairing_topic": true, + } + }); + let response = mm.rpc(&request).await.unwrap(); + assert_eq!(response.0, StatusCode::OK, "'wc_session' failed: {}", response.1); + let response: RpcV2Response = json::from_str(&response.1).unwrap(); + let GetSessionResponse { session } = response.result; + if let Some(session) = session { + return session.topic.to_string(); + } + Timer::sleep(1.).await; + } +} diff --git a/mm2src/mm2_test_helpers/src/lib.rs b/mm2src/mm2_test_helpers/src/lib.rs index ef4e81eb9b..769c7ea2cd 100644 --- a/mm2src/mm2_test_helpers/src/lib.rs +++ b/mm2src/mm2_test_helpers/src/lib.rs @@ -1,4 +1,5 @@ -#[macro_use] extern crate serde_derive; +#[macro_use] +extern crate serde_derive; pub mod electrums; pub mod for_tests; diff --git a/mm2src/mm2_test_helpers/src/structs.rs b/mm2src/mm2_test_helpers/src/structs.rs index d1f601eb04..d328a04be3 100644 --- a/mm2src/mm2_test_helpers/src/structs.rs +++ b/mm2src/mm2_test_helpers/src/structs.rs @@ -402,7 +402,9 @@ pub struct TradePreimageResponse { } impl TradePreimageResponse { - pub fn sort_total_fees(&mut self) { self.result.sort_total_fees() } + pub fn sort_total_fees(&mut self) { + self.result.sort_total_fees() + } } #[derive(Debug, Deserialize)] @@ -720,6 +722,15 @@ pub enum InitEthWithTokensStatus { UserActionRequired(Json), } +/// Error type for non-panicking task enable helpers. +#[derive(Debug)] +pub enum TaskEnableError { + /// Task timed out waiting for completion. + Timeout { ticker: String, timeout_sec: u64 }, + /// RPC returned an error status. + RpcError(Json), +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields, tag = "status", content = "details")] pub enum InitErc20TokenStatus { @@ -1235,6 +1246,20 @@ pub struct TokenInfoResponse { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateConnectionResponse { + pub url: String, + pub pairing_topic: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GetSessionResponse { + pub session: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] pub struct SpentUtxo { pub txid: String, pub vout: u32, diff --git a/mm2src/trading_api/src/lib.rs b/mm2src/trading_api/src/lib.rs index 183e6d9bcd..b54187f782 100644 --- a/mm2src/trading_api/src/lib.rs +++ b/mm2src/trading_api/src/lib.rs @@ -1,3 +1,7 @@ //! This module is for indirect connection to third-party trading APIs, processing their results and errors +// TODO: Remove this allow when Rust 1.92 regression is fixed. +// See: https://github.com/rust-lang/rust/issues/147648 +#![allow(unused_assignments)] + pub mod one_inch_api; diff --git a/mm2src/trezor/AGENTS.md b/mm2src/trezor/AGENTS.md new file mode 100644 index 0000000000..4e57b50103 --- /dev/null +++ b/mm2src/trezor/AGENTS.md @@ -0,0 +1,242 @@ +# trezor β€” Hardware Wallet Integration + +> **Note:** Always follow the root `/CLAUDE.md` for global conventions (fmt, clippy, error handling, etc.). + +Trezor hardware wallet API for UTXO and EVM transaction signing. Handles device communication, user interactions (PIN/passphrase/button), and transaction signing protocols. + +## Responsibilities + +- Device communication via Transport trait (USB, WebUSB, UDP) +- Session management with mutex-protected device access +- User interaction handling (PIN matrix, passphrase, button confirm) +- UTXO address derivation and transaction signing +- EVM address derivation and transaction signing (Legacy, EIP-1559) +- Protobuf message encoding/decoding + +## Module Structure + +``` +src/ +β”œβ”€β”€ lib.rs # Exports, TrezorMessageType enum +β”œβ”€β”€ client.rs # TrezorClient, TrezorSession +β”œβ”€β”€ response.rs # TrezorResponse enum, interaction requests +β”œβ”€β”€ response_processor.rs # TrezorRequestProcessor trait +β”œβ”€β”€ result_handler.rs # ResultHandler for response parsing +β”œβ”€β”€ error.rs # TrezorError, OperationFailure +β”œβ”€β”€ device_info.rs # TrezorDeviceInfo from Features +β”œβ”€β”€ user_interaction.rs # PIN/passphrase response types +β”œβ”€β”€ trezor_rpc_task.rs # RPC task integration +β”œβ”€β”€ transport/ # Communication layer +β”‚ β”œβ”€β”€ mod.rs # Transport trait, device IDs +β”‚ β”œβ”€β”€ protocol.rs # Wire protocol +β”‚ β”œβ”€β”€ usb.rs # Native USB (rusb) +β”‚ β”œβ”€β”€ webusb.rs # WASM WebUSB +β”‚ └── udp.rs # Emulator (testing) +β”œβ”€β”€ proto/ # Protobuf messages +β”‚ β”œβ”€β”€ mod.rs # ProtoMessage, TrezorMessage trait +β”‚ β”œβ”€β”€ messages.rs # MessageType enum +β”‚ β”œβ”€β”€ messages_common.rs +β”‚ β”œβ”€β”€ messages_management.rs +β”‚ β”œβ”€β”€ messages_bitcoin.rs +β”‚ β”œβ”€β”€ messages_ethereum.rs +β”‚ └── messages_ethereum_definitions.rs +β”œβ”€β”€ utxo/ # Bitcoin/UTXO operations +β”‚ β”œβ”€β”€ mod.rs +β”‚ β”œβ”€β”€ utxo_command.rs # get_utxo_address, get_public_key +β”‚ β”œβ”€β”€ sign_utxo.rs # Transaction signing +β”‚ β”œβ”€β”€ unsigned_tx.rs # UnsignedUtxoTx types +β”‚ └── prev_tx.rs # Previous transaction data +└── eth/ # Ethereum operations + β”œβ”€β”€ mod.rs + β”œβ”€β”€ eth_command.rs # get_eth_address, sign_eth_tx + └── definitions/ # Network definition files (.dat) +``` + +## Core Types + +### TrezorClient & TrezorSession + +```rust +// Thread-safe client with mutex-protected access +pub struct TrezorClient { + inner: Arc>, +} + +// Holds exclusive device access during operations +pub struct TrezorSession<'a> { + inner: AsyncMutexGuard<'a, TrezorClientImpl>, + pub processor: Option>>, +} + +// Create and use +let client = TrezorClient::from_transport(usb_transport); +let (device_info, session) = client.init_new_session(processor).await?; +let address = session.get_utxo_address(path, coin, false, None).await?; +``` + +### TrezorResponse + +Device responses can be ready or require user interaction: + +```rust +pub enum TrezorResponse<'a, 'b, T> { + Ready(T), // Result available + ButtonRequest(ButtonRequest), // Needs button confirm + PinMatrixRequest(PinMatrixRequest), // Needs PIN entry + PassphraseRequest(PassphraseRequest), // Needs passphrase +} +``` + +### TrezorRequestProcessor + +Trait for handling user interactions during device operations: + +```rust +#[async_trait] +pub trait TrezorRequestProcessor: Send + Sync { + type Error: NotMmError + Send; + + async fn on_button_request(&self) -> MmResult<(), TrezorProcessingError>; + async fn on_pin_request(&self) -> MmResult>; + async fn on_passphrase_request(&self) -> MmResult>; + async fn on_ready(&self) -> MmResult<(), TrezorProcessingError>; +} +``` + +### Transport Trait + +Platform-agnostic device communication: + +```rust +#[async_trait] +pub trait Transport { + async fn session_begin(&mut self) -> TrezorResult<()>; + async fn session_end(&mut self) -> TrezorResult<()>; + async fn write_message(&mut self, message: ProtoMessage) -> TrezorResult<()>; + async fn read_message(&mut self) -> TrezorResult; +} +``` + +## Device Operations + +### UTXO Operations + +```rust +// Get UTXO address +session.get_utxo_address( + path, // DerivationPath + "Bitcoin", // Coin name for Trezor + false, // show_display + Some(script_type), +).await? + +// Get public key (xpub) +session.get_public_key( + path, + "Bitcoin", + EcdsaCurve::Secp256k1, + false, // show_display + true, // ignore_xpub_magic +).await? +``` + +### EVM Operations + +```rust +// Get Ethereum address +session.get_eth_address(&derivation_path, false).await? + +// Sign transaction (Legacy or EIP-1559) +session.sign_eth_tx(&derivation_path, &unsigned_tx, chain_id).await? +``` + +## Platform Support + +| Transport | Platform | Feature Flag | +|-----------|----------|--------------| +| USB (rusb) | Native (Linux, macOS, Windows) | default | +| WebUSB | WASM (browser) | default | +| UDP | Native (emulator testing) | `trezor-udp` | + +Note: iOS not supported (no USB access). + +## Error Handling + +```rust +pub enum TrezorError { + TransportNotSupported { transport }, + ErrorRequestingAccessPermission(String), // Browser permission denied + DeviceDisconnected, + UnderlyingError(String), + ProtocolError(String), + UnexpectedMessageType(MessageType), + Failure(OperationFailure), + UnexpectedInteractionRequest(TrezorUserInteraction), + Internal(String), + PongMessageMismatch, + InternalNoProcessor, +} + +pub enum OperationFailure { + InvalidPin, + UnexpectedMessage, + ButtonExpected, + DataError, + PinExpected, + InvalidSignature, + ProcessError, + NotEnoughFunds, + NotInitialized, + WipeCodeMismatch, + InvalidSession, + FirmwareError, + FailureMessageNotFound, + UserCancelled, +} +``` + +## Interactions + +| Crate | Usage | +|-------|-------| +| **coins/utxo** | Hardware wallet signing via `PrivKeyBuildPolicy::Trezor` | +| **coins/eth** | EVM hardware wallet signing | +| **crypto** | `PrivKeyBuildPolicy` determines when Trezor is used | +| **rpc_task** | Integrates with task system for async user interaction | +| **hw_common** | Shared hardware wallet transport abstractions | +| **mm2_bitcoin** | Key and transaction types (chain, keys, script) | +| **mm2_err_handle** | MmError framework | + +## Key Invariants + +- Device access is mutex-protected; only one operation at a time +- Session must be initialized before operations (`init_new_session`) +- All signing operations require a `TrezorRequestProcessor` for user interactions +- PIN is entered via 3x3 matrix mapping (not actual digits) +- EIP-2930 transactions not supported + +## Adding New Coin Support + +For UTXO coins: +1. Ensure coin name is recognized by Trezor firmware +2. Use existing `get_utxo_address` / `get_public_key` methods +3. Script type must match derivation path (e.g., `m/84'` β†’ SegWit) + +For EVM chains: +1. Add network definition file to `eth/definitions/` +2. Register in `ETH_NETWORK_DEFS` map +3. For tokens, add to `ETH_TOKEN_DEFS` map + +## Tests + +- Unit: `cargo test -p trezor --lib` +- With emulator: Enable `trezor-udp` feature, run Trezor emulator + +## Common Pitfalls + +| Issue | Solution | +|-------|----------| +| "Device disconnected" during operation | Ensure session lock held throughout | +| Wrong address for script type | Derivation path must match script type | +| Signing hangs | Implement `TrezorRequestProcessor` to handle button/PIN prompts | +| EVM signing fails on custom chain | Add network definition to `ETH_NETWORK_DEFS` | diff --git a/scripts/ci/docker-test-nodes-setup.sh b/scripts/ci/docker-test-nodes-setup.sh new file mode 100755 index 0000000000..77b191a64f --- /dev/null +++ b/scripts/ci/docker-test-nodes-setup.sh @@ -0,0 +1,205 @@ +#!/bin/bash +# +# Setup script for KDF docker test nodes +# +# This script prepares the container runtime directory and configuration files +# needed by the docker-compose test environment. +# +# Usage: +# ./scripts/ci/docker-test-nodes-setup.sh [--skip-cosmos] [--skip-sia] +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +CONTAINER_STATE_DIR="$PROJECT_ROOT/.docker/container-state" +CONTAINER_RUNTIME_DIR="$PROJECT_ROOT/.docker/container-runtime" + +SKIP_COSMOS=false +SKIP_SIA=false + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --skip-cosmos) + SKIP_COSMOS=true + shift + ;; + --skip-sia) + SKIP_SIA=true + shift + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +echo "=== KDF Docker Test Nodes Setup ===" +echo "Project root: $PROJECT_ROOT" + +# ============================================================================ +# Prepare runtime directory for Cosmos nodes +# ============================================================================ + +if [ "$SKIP_COSMOS" = false ]; then + echo "" + echo "Preparing Cosmos node runtime directories..." + + if [ ! -d "$CONTAINER_STATE_DIR" ]; then + echo "ERROR: Container state directory not found: $CONTAINER_STATE_DIR" + exit 1 + fi + + # Remove existing runtime directory to start fresh + if [ -d "$CONTAINER_RUNTIME_DIR" ]; then + echo "Removing existing runtime directory..." + rm -rf "$CONTAINER_RUNTIME_DIR" + fi + + # Copy container state to runtime directory + echo "Copying container state to runtime directory..." + cp -r "$CONTAINER_STATE_DIR" "$CONTAINER_RUNTIME_DIR" + + # Set proper permissions + chmod -R 755 "$CONTAINER_RUNTIME_DIR" + + echo "Cosmos node data prepared at: $CONTAINER_RUNTIME_DIR" +else + echo "Skipping Cosmos node setup (--skip-cosmos)" +fi + +# ============================================================================ +# Prepare Sia configuration +# ============================================================================ + +if [ "$SKIP_SIA" = false ]; then + echo "" + echo "Preparing Sia node configuration..." + + SIA_CONFIG_DIR="$CONTAINER_RUNTIME_DIR/sia-config" + mkdir -p "$SIA_CONFIG_DIR" + + # Write walletd.yml + cat > "$SIA_CONFIG_DIR/walletd.yml" << 'EOF' +http: + address: :9980 + password: password + publicEndpoints: false +index: + mode: full +log: + stdout: + enabled: true + level: debug + format: human +EOF + + # Write ci_network.json + cat > "$SIA_CONFIG_DIR/ci_network.json" << 'EOF' +{ + "network": { + "name": "komodo-ci", + "initialCoinbase": "300000000000000000000000000000", + "minimumCoinbase": "30000000000000000000000000000", + "initialTarget": "0100000000000000000000000000000000000000000000000000000000000000", + "blockInterval": 60000000000, + "maturityDelay": 10, + "hardforkDevAddr": { + "height": 1, + "oldAddress": "000000000000000000000000000000000000000000000000000000000000000089eb0d6a8a69", + "newAddress": "000000000000000000000000000000000000000000000000000000000000000089eb0d6a8a69" + }, + "hardforkTax": { + "height": 2 + }, + "hardforkStorageProof": { + "height": 5 + }, + "hardforkOak": { + "height": 10, + "fixHeight": 12, + "genesisTimestamp": "2023-01-13T00:53:20-08:00" + }, + "hardforkASIC": { + "height": 20, + "oakTime": 600000000000, + "oakTarget": "0100000000000000000000000000000000000000000000000000000000000000", + "nonceFactor": 1009 + }, + "hardforkFoundation": { + "height": 30, + "primaryAddress": "053b2def3cbdd078c19d62ce2b4f0b1a3c5e0ffbeeff01280efb1f8969b2f5bb4fdc680f0807", + "failsafeAddress": "000000000000000000000000000000000000000000000000000000000000000089eb0d6a8a69" + }, + "hardforkV2": { + "allowHeight": 30, + "requireHeight": 7777777, + "finalCutHeight": 8888888 + } + }, + "genesis": { + "parentID": "0000000000000000000000000000000000000000000000000000000000000000", + "nonce": 0, + "timestamp": "2023-01-13T00:53:20-08:00", + "minerPayouts": null, + "transactions": [ + { + "id": "268ef8627241b3eb505cea69b21379c4b91c21dfc4b3f3f58c66316249058cfd", + "siacoinOutputs": [ + { + "value": "1000000000000000000000000000000000000", + "address": "a0cfbc1089d129f52d00bc0b0fac190d4d87976a1d7f34da7ca0c295c99a628de344d19ad469" + } + ], + "siafundOutputs": [ + { + "value": 10000, + "address": "053b2def3cbdd078c19d62ce2b4f0b1a3c5e0ffbeeff01280efb1f8969b2f5bb4fdc680f0807" + } + ] + } + ] + } +} +EOF + + echo "Sia configuration written to: $SIA_CONFIG_DIR" +else + echo "Skipping Sia setup (--skip-sia)" +fi + +# ============================================================================ +# Export environment variables for docker-compose +# ============================================================================ + +echo "" +echo "=== Environment Variables ===" +echo "Set these environment variables before running docker-compose:" +echo "" +echo " export KDF_CONTAINER_RUNTIME_DIR=$CONTAINER_RUNTIME_DIR" +echo " export ZCASH_PARAMS_PATH=\${HOME}/.zcash-params" +echo "" +echo "Or use the defaults in the compose file." + +# ============================================================================ +# Summary +# ============================================================================ + +echo "" +echo "=== Setup Complete ===" +echo "" +echo "To start the test nodes:" +echo " docker compose -f .docker/test-nodes.yml --profile all up -d" +echo "" +echo "To start specific profiles:" +echo " docker compose -f .docker/test-nodes.yml --profile utxo --profile evm up -d" +echo "" +echo "To view logs:" +echo " docker compose -f .docker/test-nodes.yml logs -f" +echo "" +echo "To stop and cleanup:" +echo " docker compose -f .docker/test-nodes.yml down -v"