Skip to content

Commit 860738b

Browse files
authored
test(ci): add regtest environment with Esplora for integration tests (#26)
Add a Docker Compose-based regtest environment using blockstream/esplora for deterministic integration tests independent of external networks.
1 parent 4bcf525 commit 860738b

5 files changed

Lines changed: 189 additions & 41 deletions

File tree

.github/workflows/build-lint-test.yml

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,56 @@ jobs:
6060
run: yarn lint
6161
- name: Test
6262
working-directory: tests/node
63-
run: yarn build && yarn test
63+
run: yarn build && yarn jest --testPathIgnorePatterns='integration/esplora'
64+
65+
esplora-integration:
66+
name: Esplora integration tests
67+
runs-on: ubuntu-latest
68+
steps:
69+
- uses: actions/checkout@v4
70+
- name: Enable Corepack
71+
run: corepack enable
72+
- name: Install wasm-pack
73+
run: curl https://raw.githubusercontent.com/rustwasm/wasm-pack/a3a48401795cd4b3afe1d74568c93675a04f3970/installer/init.sh -sSf | sh -s -- -f
74+
- name: Rust Cache
75+
uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3
76+
- name: Setup Node
77+
uses: actions/setup-node@v4
78+
with:
79+
node-version: 22.x
80+
cache: yarn
81+
cache-dependency-path: tests/node/yarn.lock
82+
- name: Install dependencies
83+
working-directory: tests/node
84+
run: yarn install --immutable
85+
- name: Build WASM (Node target)
86+
working-directory: tests/node
87+
run: yarn build
88+
- name: Start Esplora regtest
89+
run: docker compose -f tests/docker-compose.yml up -d
90+
- name: Wait for Esplora
91+
run: |
92+
for i in $(seq 1 60); do
93+
if curl -sf http://localhost:8094/regtest/api/blocks/tip/height > /dev/null 2>&1; then
94+
echo "Esplora is ready"
95+
exit 0
96+
fi
97+
echo "Waiting... ($i/60)"
98+
sleep 3
99+
done
100+
echo "Esplora did not start in time"
101+
docker compose -f tests/docker-compose.yml logs
102+
exit 1
103+
- name: Fund test wallet
104+
run: docker exec esplora-regtest bash /init-esplora.sh
105+
- name: Wait for Esplora indexing
106+
run: sleep 10
107+
- name: Run Esplora integration tests
108+
working-directory: tests/node
109+
run: NETWORK=regtest ESPLORA_URL=http://localhost:8094/regtest/api yarn jest --testPathPattern='integration/esplora'
110+
- name: Stop Esplora
111+
if: always()
112+
run: docker compose -f tests/docker-compose.yml down
64113

65114
lint:
66115
name: Lint (fmt + clippy)

tests/docker-compose.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
services:
2+
esplora:
3+
image: blockstream/esplora
4+
container_name: esplora-regtest
5+
ports:
6+
- "8094:80"
7+
volumes:
8+
- ./init-esplora.sh:/init-esplora.sh
9+
entrypoint:
10+
- bash
11+
- -c
12+
- "/srv/explorer/run.sh bitcoin-regtest explorer"

tests/init-esplora.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/bin/bash
2+
set -e
3+
4+
# Load or create the default wallet
5+
cli -regtest createwallet default 2>/dev/null || cli -regtest loadwallet default || true
6+
7+
# Generate initial blocks to make coins spendable (100+ confirmations needed for coinbase)
8+
MINER_ADDRESS=$(cli -regtest getnewaddress)
9+
cli -regtest generatetoaddress 101 "$MINER_ADDRESS"
10+
11+
# Fund the test wallet's first external address (index 0)
12+
# Derived from descriptor: wpkh(tprv8ZgxMBicQKsPd5puBG1xsJ5V53vVPfCy2gnZfsqzmDSDjaQx8LEW4REFvrj6PQMuer7NqZeBiy9iP9ucqJZiveeEGqQ5CvcfV6SPcy8LQR7/84'/1'/0'/0/*)
13+
# Address at index 0 on regtest: bcrt1qkn59f87tznmmjw5nu6ng8p7k6vcur2emmngn5j
14+
RECEIVER_ADDRESS="bcrt1qkn59f87tznmmjw5nu6ng8p7k6vcur2emmngn5j"
15+
16+
AMOUNT=1.0
17+
TXID=$(cli -regtest -rpcwallet=default sendtoaddress "$RECEIVER_ADDRESS" $AMOUNT)
18+
echo "Transaction sent. TXID: $TXID"
19+
20+
echo "Mining 10 blocks to confirm transaction..."
21+
cli -regtest generatetoaddress 10 "$MINER_ADDRESS"
22+
23+
echo "Setup complete. Funds sent to $RECEIVER_ADDRESS."
Lines changed: 59 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {
2-
Address,
32
Amount,
43
EsploraClient,
54
FeeRate,
@@ -12,20 +11,26 @@ import {
1211
TxOrdering,
1312
} from "../../../pkg/bitcoindevkit";
1413

14+
// Network configuration via environment variables.
15+
// Defaults to Mutinynet signet for backward compatibility.
16+
// Set ESPLORA_URL and NETWORK to override (e.g. for regtest CI).
17+
const network: Network = (process.env.NETWORK as Network) || "signet";
18+
const esploraUrl = process.env.ESPLORA_URL || "https://mutinynet.com/api";
19+
20+
// Expected first external address per network (same descriptor, different bech32 HRP)
21+
const expectedAddress: Record<string, string> = {
22+
signet: "tb1qkn59f87tznmmjw5nu6ng8p7k6vcur2eme637rm",
23+
regtest: "bcrt1qkn59f87tznmmjw5nu6ng8p7k6vcur2emmngn5j",
24+
};
25+
1526
// Tests are expected to run in order
16-
describe("Esplora client", () => {
17-
const stopGap = 2;
18-
const parallelRequests = 10;
27+
describe(`Esplora client (${network})`, () => {
28+
const stopGap = 5;
29+
const parallelRequests = network === "regtest" ? 1 : 10;
1930
const externalDescriptor =
2031
"wpkh(tprv8ZgxMBicQKsPd5puBG1xsJ5V53vVPfCy2gnZfsqzmDSDjaQx8LEW4REFvrj6PQMuer7NqZeBiy9iP9ucqJZiveeEGqQ5CvcfV6SPcy8LQR7/84'/1'/0'/0/*)#jjcsy5wd";
2132
const internalDescriptor =
2233
"wpkh(tprv8ZgxMBicQKsPd5puBG1xsJ5V53vVPfCy2gnZfsqzmDSDjaQx8LEW4REFvrj6PQMuer7NqZeBiy9iP9ucqJZiveeEGqQ5CvcfV6SPcy8LQR7/84'/1'/0'/1/*)#rxa3ep74";
23-
const network: Network = "signet";
24-
const esploraUrl = "https://mutinynet.com/api";
25-
const recipientAddress = Address.from_string(
26-
"tb1qd28npep0s8frcm3y7dxqajkcy2m40eysplyr9v",
27-
network
28-
);
2934
const unixTimestamp = BigInt(Math.floor(Date.now() / 1000));
3035

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

3540
it("creates a new wallet", () => {
3641
wallet = Wallet.create(network, externalDescriptor, internalDescriptor);
37-
expect(wallet.peek_address("external", 0).address.toString()).toBe(
38-
"tb1qkn59f87tznmmjw5nu6ng8p7k6vcur2eme637rm"
39-
);
42+
const addr = wallet.peek_address("external", 0).address.toString();
43+
if (expectedAddress[network]) {
44+
expect(addr).toBe(expectedAddress[network]);
45+
}
4046
});
4147

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

5965
const fee = feeEstimates.get(confirmationTarget);
60-
expect(fee).toBeDefined();
61-
feeRate = new FeeRate(BigInt(Math.floor(fee)));
66+
// Regtest may not have meaningful fee estimates; use a floor of 1 sat/vbyte
67+
const feeValue = fee ?? 1;
68+
feeRate = new FeeRate(BigInt(Math.max(1, Math.floor(feeValue))));
6269
});
6370

6471
it("sends a transaction", async () => {
72+
// Send to the wallet's own address at index 5 (self-contained, works on any network)
73+
const recipientAddress = wallet.peek_address("external", 5);
6574
const sendAmount = Amount.from_sat(BigInt(1000));
6675
expect(wallet.balance.trusted_spendable.to_sat()).toBeGreaterThan(
6776
sendAmount.to_sat()
@@ -71,10 +80,12 @@ describe("Esplora client", () => {
7180
const psbt = wallet
7281
.build_tx()
7382
.fee_rate(feeRate)
74-
.add_recipient(new Recipient(recipientAddress.script_pubkey, sendAmount))
83+
.add_recipient(
84+
new Recipient(recipientAddress.address.script_pubkey, sendAmount)
85+
)
7586
.finish();
7687

77-
expect(psbt.fee().to_sat()).toBeGreaterThan(100); // We cannot know the exact fees
88+
expect(psbt.fee().to_sat()).toBeGreaterThan(BigInt(0));
7889

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

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

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

111-
it("fills inputs of an output-only Psbt", () => {
112-
const psbtBase64 =
113-
"cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUtOhUn8sU97k6k+amg4fW0zHBqzsAAAAAAAAAAAA=";
114-
const template = Psbt.from_string(psbtBase64);
127+
// PSBT template test only runs on signet (the base64 encodes signet-specific data)
128+
if (network === "signet") {
129+
it("fills inputs of an output-only Psbt", () => {
130+
const psbtBase64 =
131+
"cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUtOhUn8sU97k6k+amg4fW0zHBqzsAAAAAAAAAAAA=";
132+
const template = Psbt.from_string(psbtBase64);
115133

116-
let builder = wallet
117-
.build_tx()
118-
.fee_rate(new FeeRate(BigInt(1)))
119-
.ordering(TxOrdering.Untouched);
120-
121-
for (const txout of template.unsigned_tx.output) {
122-
if (wallet.is_mine(txout.script_pubkey)) {
123-
builder = builder.drain_to(txout.script_pubkey);
124-
} else {
125-
const recipient = new Recipient(txout.script_pubkey, txout.value);
126-
builder = builder.add_recipient(recipient);
134+
let builder = wallet
135+
.build_tx()
136+
.fee_rate(new FeeRate(BigInt(1)))
137+
.ordering(TxOrdering.Untouched);
138+
139+
for (const txout of template.unsigned_tx.output) {
140+
if (wallet.is_mine(txout.script_pubkey)) {
141+
builder = builder.drain_to(txout.script_pubkey);
142+
} else {
143+
const recipient = new Recipient(txout.script_pubkey, txout.value);
144+
builder = builder.add_recipient(recipient);
145+
}
127146
}
128-
}
129147

130-
const psbt = builder.finish();
131-
expect(psbt.unsigned_tx.output).toHaveLength(
132-
template.unsigned_tx.output.length
133-
);
134-
expect(psbt.unsigned_tx.tx_out(2).value.to_btc()).toBeGreaterThan(0);
135-
});
148+
const psbt = builder.finish();
149+
expect(psbt.unsigned_tx.output).toHaveLength(
150+
template.unsigned_tx.output.length
151+
);
152+
expect(psbt.unsigned_tx.tx_out(2).value.to_btc()).toBeGreaterThan(0);
153+
});
154+
}
136155
});

tests/run-integration.sh

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/bin/bash
2+
set -e
3+
4+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5+
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
6+
COMPOSE_FILE="$COMPOSE_FILE"
7+
8+
cleanup() {
9+
echo "Stopping Docker services..."
10+
docker compose -f "$COMPOSE_FILE" down
11+
}
12+
trap cleanup EXIT
13+
14+
echo "Starting Docker services..."
15+
docker compose -f "$COMPOSE_FILE" up -d
16+
17+
echo "Waiting for Esplora to be ready..."
18+
MAX_RETRIES=60
19+
RETRY=0
20+
until curl -sf http://localhost:8094/regtest/api/blocks/tip/height > /dev/null 2>&1; do
21+
RETRY=$((RETRY + 1))
22+
if [ "$RETRY" -ge "$MAX_RETRIES" ]; then
23+
echo "Error: Esplora did not become ready in time"
24+
exit 1
25+
fi
26+
echo " Waiting... ($RETRY/$MAX_RETRIES)"
27+
sleep 3
28+
done
29+
echo "Esplora is ready."
30+
31+
echo "Initializing regtest environment..."
32+
docker exec esplora-regtest bash /init-esplora.sh
33+
34+
# Wait for Esplora to index the new blocks
35+
echo "Waiting for Esplora to index blocks..."
36+
sleep 10
37+
38+
echo "Running regtest integration tests..."
39+
cd "$PROJECT_ROOT/tests/node"
40+
set +e
41+
NETWORK=regtest ESPLORA_URL=http://localhost:8094/regtest/api yarn jest --testPathPattern='integration/esplora'
42+
TEST_EXIT_CODE=$?
43+
set -e
44+
45+
exit $TEST_EXIT_CODE

0 commit comments

Comments
 (0)