Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion .github/workflows/build-lint-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,56 @@ jobs:
run: yarn lint
- name: Test
working-directory: tests/node
run: yarn build && yarn test
run: yarn build && yarn jest --testPathIgnorePatterns='integration/esplora'

esplora-integration:
name: Esplora integration tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Install wasm-pack
run: curl https://raw.githubusercontent.com/rustwasm/wasm-pack/a3a48401795cd4b3afe1d74568c93675a04f3970/installer/init.sh -sSf | sh -s -- -f
- name: Rust Cache
uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: yarn
cache-dependency-path: tests/node/yarn.lock
- name: Install dependencies
working-directory: tests/node
run: yarn install --immutable
- name: Build WASM (Node target)
working-directory: tests/node
run: yarn build
- name: Start Esplora regtest
run: docker compose -f tests/docker-compose.yml up -d
- name: Wait for Esplora
run: |
for i in $(seq 1 60); do
if curl -sf http://localhost:8094/regtest/api/blocks/tip/height > /dev/null 2>&1; then
echo "Esplora is ready"
exit 0
fi
echo "Waiting... ($i/60)"
sleep 3
done
echo "Esplora did not start in time"
docker compose -f tests/docker-compose.yml logs
exit 1
- name: Fund test wallet
run: docker exec esplora-regtest bash /init-esplora.sh
- name: Wait for Esplora indexing
run: sleep 10
- name: Run Esplora integration tests
working-directory: tests/node
run: NETWORK=regtest ESPLORA_URL=http://localhost:8094/regtest/api yarn jest --testPathPattern='integration/esplora'
- name: Stop Esplora
if: always()
run: docker compose -f tests/docker-compose.yml down

lint:
name: Lint (fmt + clippy)
Expand Down
12 changes: 12 additions & 0 deletions tests/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
services:
esplora:
image: blockstream/esplora
container_name: esplora-regtest
ports:
- "8094:80"
volumes:
- ./init-esplora.sh:/init-esplora.sh
entrypoint:
- bash
- -c
- "/srv/explorer/run.sh bitcoin-regtest explorer"
23 changes: 23 additions & 0 deletions tests/init-esplora.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/bin/bash
set -e

# Load or create the default wallet
cli -regtest createwallet default 2>/dev/null || cli -regtest loadwallet default || true

# Generate initial blocks to make coins spendable (100+ confirmations needed for coinbase)
MINER_ADDRESS=$(cli -regtest getnewaddress)
cli -regtest generatetoaddress 101 "$MINER_ADDRESS"

# Fund the test wallet's first external address (index 0)
# Derived from descriptor: wpkh(tprv8ZgxMBicQKsPd5puBG1xsJ5V53vVPfCy2gnZfsqzmDSDjaQx8LEW4REFvrj6PQMuer7NqZeBiy9iP9ucqJZiveeEGqQ5CvcfV6SPcy8LQR7/84'/1'/0'/0/*)
# Address at index 0 on regtest: bcrt1qkn59f87tznmmjw5nu6ng8p7k6vcur2emmngn5j
RECEIVER_ADDRESS="bcrt1qkn59f87tznmmjw5nu6ng8p7k6vcur2emmngn5j"

AMOUNT=1.0
TXID=$(cli -regtest -rpcwallet=default sendtoaddress "$RECEIVER_ADDRESS" $AMOUNT)
echo "Transaction sent. TXID: $TXID"

echo "Mining 10 blocks to confirm transaction..."
cli -regtest generatetoaddress 10 "$MINER_ADDRESS"

echo "Setup complete. Funds sent to $RECEIVER_ADDRESS."
99 changes: 59 additions & 40 deletions tests/node/integration/esplora.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
Address,
Amount,
EsploraClient,
FeeRate,
Expand All @@ -12,20 +11,26 @@ import {
TxOrdering,
} from "../../../pkg/bitcoindevkit";

// Network configuration via environment variables.
// Defaults to Mutinynet signet for backward compatibility.
// Set ESPLORA_URL and NETWORK to override (e.g. for regtest CI).
const network: Network = (process.env.NETWORK as Network) || "signet";
const esploraUrl = process.env.ESPLORA_URL || "https://mutinynet.com/api";

// Expected first external address per network (same descriptor, different bech32 HRP)
const expectedAddress: Record<string, string> = {
signet: "tb1qkn59f87tznmmjw5nu6ng8p7k6vcur2eme637rm",
regtest: "bcrt1qkn59f87tznmmjw5nu6ng8p7k6vcur2emmngn5j",
};

// Tests are expected to run in order
describe("Esplora client", () => {
const stopGap = 2;
const parallelRequests = 10;
describe(`Esplora client (${network})`, () => {
const stopGap = 5;
const parallelRequests = network === "regtest" ? 1 : 10;
const externalDescriptor =
"wpkh(tprv8ZgxMBicQKsPd5puBG1xsJ5V53vVPfCy2gnZfsqzmDSDjaQx8LEW4REFvrj6PQMuer7NqZeBiy9iP9ucqJZiveeEGqQ5CvcfV6SPcy8LQR7/84'/1'/0'/0/*)#jjcsy5wd";
const internalDescriptor =
"wpkh(tprv8ZgxMBicQKsPd5puBG1xsJ5V53vVPfCy2gnZfsqzmDSDjaQx8LEW4REFvrj6PQMuer7NqZeBiy9iP9ucqJZiveeEGqQ5CvcfV6SPcy8LQR7/84'/1'/0'/1/*)#rxa3ep74";
const network: Network = "signet";
const esploraUrl = "https://mutinynet.com/api";
const recipientAddress = Address.from_string(
"tb1qd28npep0s8frcm3y7dxqajkcy2m40eysplyr9v",
network
);
const unixTimestamp = BigInt(Math.floor(Date.now() / 1000));

let feeRate: FeeRate;
Expand All @@ -34,9 +39,10 @@ describe("Esplora client", () => {

it("creates a new wallet", () => {
wallet = Wallet.create(network, externalDescriptor, internalDescriptor);
expect(wallet.peek_address("external", 0).address.toString()).toBe(
"tb1qkn59f87tznmmjw5nu6ng8p7k6vcur2eme637rm"
);
const addr = wallet.peek_address("external", 0).address.toString();
if (expectedAddress[network]) {
expect(addr).toBe(expectedAddress[network]);
}
});

it("performs full scan on a wallet", async () => {
Expand All @@ -57,11 +63,14 @@ describe("Esplora client", () => {
const feeEstimates = await esploraClient.get_fee_estimates();

const fee = feeEstimates.get(confirmationTarget);
expect(fee).toBeDefined();
feeRate = new FeeRate(BigInt(Math.floor(fee)));
// Regtest may not have meaningful fee estimates; use a floor of 1 sat/vbyte
const feeValue = fee ?? 1;
feeRate = new FeeRate(BigInt(Math.max(1, Math.floor(feeValue))));
});

it("sends a transaction", async () => {
// Send to the wallet's own address at index 5 (self-contained, works on any network)
const recipientAddress = wallet.peek_address("external", 5);
const sendAmount = Amount.from_sat(BigInt(1000));
expect(wallet.balance.trusted_spendable.to_sat()).toBeGreaterThan(
sendAmount.to_sat()
Expand All @@ -71,10 +80,12 @@ describe("Esplora client", () => {
const psbt = wallet
.build_tx()
.fee_rate(feeRate)
.add_recipient(new Recipient(recipientAddress.script_pubkey, sendAmount))
.add_recipient(
new Recipient(recipientAddress.address.script_pubkey, sendAmount)
)
.finish();

expect(psbt.fee().to_sat()).toBeGreaterThan(100); // We cannot know the exact fees
expect(psbt.fee().to_sat()).toBeGreaterThan(BigInt(0));

const finalized = wallet.sign(psbt, new SignOptions());
expect(finalized).toBeTruthy();
Expand All @@ -85,7 +96,12 @@ describe("Esplora client", () => {

// Assert that we are aware of newly created addresses that were revealed during PSBT creation
const currentDerivationIndex = wallet.derivation_index("internal");
expect(initialDerivationIndex).toBeLessThan(currentDerivationIndex);
if (initialDerivationIndex !== undefined) {
expect(initialDerivationIndex).toBeLessThan(currentDerivationIndex);
} else {
// Fresh wallet had no internal derivation index; after building a tx with change it should exist
expect(currentDerivationIndex).toBeDefined();
}

// Assert that the transaction is in the wallet
wallet.apply_unconfirmed_txs([new UnconfirmedTx(tx, unixTimestamp)]);
Expand All @@ -108,29 +124,32 @@ describe("Esplora client", () => {
}).toThrow();
});

it("fills inputs of an output-only Psbt", () => {
const psbtBase64 =
"cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUtOhUn8sU97k6k+amg4fW0zHBqzsAAAAAAAAAAAA=";
const template = Psbt.from_string(psbtBase64);
// PSBT template test only runs on signet (the base64 encodes signet-specific data)
if (network === "signet") {
it("fills inputs of an output-only Psbt", () => {
const psbtBase64 =
"cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUtOhUn8sU97k6k+amg4fW0zHBqzsAAAAAAAAAAAA=";
const template = Psbt.from_string(psbtBase64);

let builder = wallet
.build_tx()
.fee_rate(new FeeRate(BigInt(1)))
.ordering(TxOrdering.Untouched);

for (const txout of template.unsigned_tx.output) {
if (wallet.is_mine(txout.script_pubkey)) {
builder = builder.drain_to(txout.script_pubkey);
} else {
const recipient = new Recipient(txout.script_pubkey, txout.value);
builder = builder.add_recipient(recipient);
let builder = wallet
.build_tx()
.fee_rate(new FeeRate(BigInt(1)))
.ordering(TxOrdering.Untouched);

for (const txout of template.unsigned_tx.output) {
if (wallet.is_mine(txout.script_pubkey)) {
builder = builder.drain_to(txout.script_pubkey);
} else {
const recipient = new Recipient(txout.script_pubkey, txout.value);
builder = builder.add_recipient(recipient);
}
}
}

const psbt = builder.finish();
expect(psbt.unsigned_tx.output).toHaveLength(
template.unsigned_tx.output.length
);
expect(psbt.unsigned_tx.tx_out(2).value.to_btc()).toBeGreaterThan(0);
});
const psbt = builder.finish();
expect(psbt.unsigned_tx.output).toHaveLength(
template.unsigned_tx.output.length
);
expect(psbt.unsigned_tx.tx_out(2).value.to_btc()).toBeGreaterThan(0);
});
}
});
45 changes: 45 additions & 0 deletions tests/run-integration.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/bin/bash
set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_FILE="$COMPOSE_FILE"

cleanup() {
echo "Stopping Docker services..."
docker compose -f "$COMPOSE_FILE" down
}
trap cleanup EXIT

echo "Starting Docker services..."
docker compose -f "$COMPOSE_FILE" up -d

echo "Waiting for Esplora to be ready..."
MAX_RETRIES=60
RETRY=0
until curl -sf http://localhost:8094/regtest/api/blocks/tip/height > /dev/null 2>&1; do
RETRY=$((RETRY + 1))
if [ "$RETRY" -ge "$MAX_RETRIES" ]; then
echo "Error: Esplora did not become ready in time"
exit 1
fi
echo " Waiting... ($RETRY/$MAX_RETRIES)"
sleep 3
done
echo "Esplora is ready."

echo "Initializing regtest environment..."
docker exec esplora-regtest bash /init-esplora.sh

# Wait for Esplora to index the new blocks
echo "Waiting for Esplora to index blocks..."
sleep 10

echo "Running regtest integration tests..."
cd "$PROJECT_ROOT/tests/node"
set +e
NETWORK=regtest ESPLORA_URL=http://localhost:8094/regtest/api yarn jest --testPathPattern='integration/esplora'
TEST_EXIT_CODE=$?
set -e

exit $TEST_EXIT_CODE