Skip to content

Commit 825cc27

Browse files
authored
chore: PoX-2 integration testing for segwit addresses (#1372)
* feat: functions for generating Bitcoin addresses in all formats supported by Stacks 2.1 * test: segwit PoX-2 integration tests * test: e2e tests for PoX-2 segwit (P2WPKH) support * chore: test PoX rewards against bitcoin json-rpc results * test: e2e tests for PoX-2 rewards address formats p2pkh, p2sh, p2wpkh, p2wsh * test: use more robust bitcoin `listtransactions` RPC method for validating PoX-2 rewards * chore: update plain P2SH address generation to create a more standard P2SH(P2PKH) rather than 1-of-1 multisig address * chore: temp skip segwit tests until `feat/native-segwit` stacks-blockchain branch is merged * chore: Revert "temp skip segwit tests until `feat/native-segwit` stacks-blockchain branch is merged" -- too many other dependent changes * chore: filter bitcoin rpc `listtransactions` result for only the address being tested
1 parent f3564b2 commit 825cc27

File tree

7 files changed

+1795
-124
lines changed

7 files changed

+1795
-124
lines changed

docker/docker-compose.dev.stacks-krypton-2.1-transition.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
version: '3.7'
22
services:
33
stacks-blockchain:
4-
image: "zone117x/stacks-api-e2e:stacks2.1-transition-8fb8b77"
4+
image: "zone117x/stacks-api-e2e:stacks2.1-transition-feat-segwit-dcae03a"
55
ports:
66
- "18443:18443" # bitcoin regtest JSON-RPC interface
77
- "18444:18444" # bitcoin regtest p2p

docker/docker-compose.dev.stacks-krypton.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
version: '3.7'
22
services:
33
stacks-blockchain:
4-
image: "zone117x/stacks-api-e2e:stacks2.1-8fb8b77"
4+
image: "zone117x/stacks-api-e2e:stacks2.1-feat-segwit-dcae03a"
55
ports:
66
- "18443:18443" # bitcoin regtest JSON-RPC interface
77
- "18444:18444" # bitcoin regtest p2p
88
- "20443:20443" # stacks-node RPC interface
99
- "20444:20444" # stacks-node p2p
1010
environment:
11-
MINE_INTERVAL: 2s
11+
MINE_INTERVAL: 1.2s
1212
STACKS_EVENT_OBSERVER: host.docker.internal:3700
1313
# STACKS_LOG_TRACE: 1
1414
# STACKS_LOG_DEBUG: 1

src/ec-helpers.ts

Lines changed: 229 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import * as bitcoin from 'bitcoinjs-lib';
22
import * as ecc from 'tiny-secp256k1';
3-
import { ECPairAPI, ECPairFactory } from 'ecpair';
3+
import { ECPairAPI, ECPairFactory, ECPairInterface } from 'ecpair';
4+
import { coerceToBuffer } from './helpers';
45

5-
export { ECPairInterface } from 'ecpair';
6+
export { ECPairInterface };
67

78
export const ECPair: ECPairAPI = ECPairFactory(ecc);
89

@@ -16,27 +17,190 @@ const BITCOIN_NETWORKS = {
1617
regtest: bitcoin.networks.regtest,
1718
} as const;
1819

20+
type KeyInputArgs = { network: keyof typeof BITCOIN_NETWORKS } & (
21+
| { privateKey: Buffer | string }
22+
| { publicKey: Buffer | string }
23+
);
24+
25+
interface KeyOutput {
26+
address: string;
27+
ecPair: ECPairInterface;
28+
}
29+
30+
function ecPairFromKeyInputArgs(args: KeyInputArgs, allowXOnlyPubkey = false): ECPairInterface {
31+
const network = BITCOIN_NETWORKS[args.network];
32+
if ('privateKey' in args) {
33+
let keyBuff = coerceToBuffer(args.privateKey);
34+
if (keyBuff.length === 33 && keyBuff[32] === 0x01) {
35+
keyBuff = keyBuff.slice(0, 32); // Drop the compression byte suffix
36+
}
37+
return ECPair.fromPrivateKey(keyBuff, { compressed: true, network });
38+
} else {
39+
let keyBuff = coerceToBuffer(args.publicKey);
40+
if (allowXOnlyPubkey && keyBuff.length === 32) {
41+
// Allow x-only pubkeys, defined in BIP340 (no y parity byte prefix)
42+
const X_ONLY_PUB_KEY_TIE_BREAKER = 0x02;
43+
keyBuff = Buffer.concat([Buffer.from([X_ONLY_PUB_KEY_TIE_BREAKER]), keyBuff]);
44+
}
45+
return ECPair.fromPublicKey(keyBuff, { compressed: true, network });
46+
}
47+
}
48+
1949
/**
20-
* Function for creating a tweaked p2tr key-spend only address (this is recommended by BIP341)
21-
* @see https://github.com/bitcoinjs/bitcoinjs-lib/blob/424abf2376772bb57b7668bc35b29ed18879fa0a/test/integration/taproot.md
50+
* Creates a P2PKH "Pay To Public Key Hash" address.
51+
* `hashbytes` is the 20-byte hash160 of a single public key.
52+
* Encoded as base58.
53+
*/
54+
function p2pkhAddressFromKey(args: KeyInputArgs): KeyOutput {
55+
const network = BITCOIN_NETWORKS[args.network];
56+
const ecPair = ecPairFromKeyInputArgs(args, true);
57+
58+
const p2pkhhResult = bitcoin.payments.p2pkh({ pubkey: ecPair.publicKey, network });
59+
if (!p2pkhhResult.address) {
60+
throw new Error(
61+
`Could not create P2PKH address from pubkey ${ecPair.publicKey.toString('hex')}`
62+
);
63+
}
64+
return { ecPair, address: p2pkhhResult.address };
65+
}
66+
67+
/**
68+
* Creates a P2SH "Pay To Script Hash" address.
69+
* Typically used to generate multi-signature wallets, however, this function creates a P2PKH wrapped in P2SH address.
70+
* `hashbytes` is the 20-byte hash160 of a redeemScript script.
71+
* Encoded as base58.
72+
*/
73+
function p2shAddressFromKey(args: KeyInputArgs): KeyOutput {
74+
const network = BITCOIN_NETWORKS[args.network];
75+
const ecPair = ecPairFromKeyInputArgs(args, true);
76+
77+
// P2SH(P2PKH) address example '3D4sXNTgnVbEWaU58pDgBD82zDkthVWazv' from https://matheo.uliege.be/bitstream/2268.2/11236/4/Master_Thesis.pdf
78+
const p2sh_p2pkh_Result = bitcoin.payments.p2sh({
79+
redeem: bitcoin.payments.p2pkh({ pubkey: ecPair.publicKey, network }),
80+
network,
81+
});
82+
83+
// P2SH(P2PK) address example '3EuJgd52Tme58nZewZa39svoDtSUgL4Mgn' from https://matheo.uliege.be/bitstream/2268.2/11236/4/Master_Thesis.pdf
84+
// const p2sh_p2pk_Result = bitcoin.payments.p2sh({
85+
// redeem: bitcoin.payments.p2pk({ pubkey: ecPair.publicKey, network }),
86+
// network,
87+
// });
88+
89+
// 1-of-1 multisig, not sure if valid ...
90+
// const p2shResult1 = bitcoin.payments.p2sh({
91+
// redeem: bitcoin.payments.p2ms({ pubkeys: [ecPair.publicKey], m: 1, network }),
92+
// network,
93+
// });
94+
95+
if (!p2sh_p2pkh_Result.address) {
96+
throw new Error(
97+
`Could not create P2SH address from pubkey ${ecPair.publicKey.toString('hex')}`
98+
);
99+
}
100+
return { ecPair, address: p2sh_p2pkh_Result.address };
101+
}
102+
103+
/**
104+
* Creates a P2SH-P2WPHK "Pay To Witness Public Key Hash Wrapped In P2SH" address.
105+
* Used to generate a segwit P2WPKH address nested in a legacy legacy P2SH address.
106+
* Allows non-SegWit wallets to generate a SegWit transaction, and allows non-SegWit client accept SegWit transaction.
107+
* `hashbytes` is the 20-byte hash160 of a p2wpkh witness script
108+
* Encoded as base58.
109+
*/
110+
function p2shp2wpkhAddressFromKey(args: KeyInputArgs): KeyOutput {
111+
const network = BITCOIN_NETWORKS[args.network];
112+
const ecPair = ecPairFromKeyInputArgs(args, true);
113+
114+
const p2shResult = bitcoin.payments.p2sh({
115+
redeem: bitcoin.payments.p2wpkh({ pubkey: ecPair.publicKey, network }),
116+
network,
117+
});
118+
if (!p2shResult.address) {
119+
throw new Error(
120+
`Could not create P2SH-P2WPHK address from pubkey ${ecPair.publicKey.toString('hex')}`
121+
);
122+
}
123+
return { ecPair, address: p2shResult.address };
124+
}
125+
126+
/**
127+
* Creates a P2SH-P2WSH "Pay To Witness Script Hash Wrapped In P2SH" address.
128+
* Used to generate a segwit P2WSH address nested in a legacy legacy P2SH address.
129+
* Typically used for multi-signature wallets, however, this function creates a 1-of-1 "multisig" address.
130+
* Allows non-SegWit wallets to generate a SegWit transaction, and allows non-SegWit client accept SegWit transaction.
22131
*/
23-
export function p2trAddressFromPublicKey(
24-
publicKey: Buffer,
25-
network: keyof typeof BITCOIN_NETWORKS
26-
): string {
27-
if (publicKey.length === 32) {
28-
// Defined in BIP340
29-
const X_ONLY_PUB_KEY_TIE_BREAKER = 0x02;
30-
publicKey = Buffer.concat([Buffer.from([X_ONLY_PUB_KEY_TIE_BREAKER]), publicKey]);
132+
function p2shp2wshAddressFromKeys(args: KeyInputArgs): KeyOutput {
133+
const network = BITCOIN_NETWORKS[args.network];
134+
const ecPair = ecPairFromKeyInputArgs(args, true);
135+
136+
const p2shResult = bitcoin.payments.p2sh({
137+
redeem: bitcoin.payments.p2wsh({
138+
redeem: bitcoin.payments.p2ms({ m: 1, pubkeys: [ecPair.publicKey], network }),
139+
network,
140+
}),
141+
network,
142+
});
143+
if (!p2shResult.address) {
144+
throw new Error(
145+
`Could not create P2SH-P2WPHK address from pubkey ${ecPair.publicKey.toString('hex')}`
146+
);
31147
}
32-
const ecPair = ECPair.fromPublicKey(publicKey, { compressed: true });
33-
const pubKeyBuffer = ecPair.publicKey;
34-
if (!pubKeyBuffer) {
35-
throw new Error(`Could not get public key`);
148+
return { ecPair, address: p2shResult.address };
149+
}
150+
151+
/**
152+
* Creates a P2WPKH "Pay To Witness Public Key Hash" address.
153+
* Used to generated standard segwit addresses.
154+
* `hashbytes` is the 20-byte hash160 of the witness script.
155+
* Encoded as SEGWIT_V0 / bech32.
156+
*/
157+
function p2wpkhAddressFromKey(args: KeyInputArgs): KeyOutput {
158+
const network = BITCOIN_NETWORKS[args.network];
159+
const ecPair = ecPairFromKeyInputArgs(args, true);
160+
161+
const p2wpkhResult = bitcoin.payments.p2wpkh({ pubkey: ecPair.publicKey, network });
162+
if (!p2wpkhResult.address) {
163+
throw new Error(
164+
`Could not create p2wpkh address from pubkey ${ecPair.publicKey.toString('hex')}`
165+
);
36166
}
167+
return { ecPair, address: p2wpkhResult.address };
168+
}
169+
170+
/**
171+
* Creates a P2WSH "Pay To Witness Script Hash" address.
172+
* Typically used to generate multi-signature segwit wallets, however, this function creates a 1-of-1 "multisig" address.
173+
* `hashbytes` is the 32-byte sha256 of the witness script.
174+
* Encoded as SEGWIT_V0 / bech32.
175+
*/
176+
function p2wshAddressFromKey(args: KeyInputArgs): KeyOutput {
177+
const network = BITCOIN_NETWORKS[args.network];
178+
const ecPair = ecPairFromKeyInputArgs(args, true);
179+
180+
const p2wshResult = bitcoin.payments.p2wsh({
181+
redeem: bitcoin.payments.p2ms({ m: 1, pubkeys: [ecPair.publicKey], network }),
182+
network,
183+
});
184+
if (!p2wshResult.address) {
185+
throw new Error(
186+
`Could not create p2wpkh address from pubkey ${ecPair.publicKey.toString('hex')}`
187+
);
188+
}
189+
return { ecPair, address: p2wshResult.address };
190+
}
191+
192+
/**
193+
* Creates a P2TR "Pay To Taproot" address.
194+
* Uses the tweaked p2tr key-spend only address encoding recommended by BIP341.
195+
* Encoded as SEGWIT_V1 / bech32m.
196+
* @see https://github.com/bitcoinjs/bitcoinjs-lib/blob/424abf2376772bb57b7668bc35b29ed18879fa0a/test/integration/taproot.md
197+
*/
198+
function p2trAddressFromKey(args: KeyInputArgs): KeyOutput {
199+
const network = BITCOIN_NETWORKS[args.network];
200+
const ecPair = ecPairFromKeyInputArgs(args, true);
37201

38202
// x-only pubkey (remove 1 byte y parity)
39-
const myXOnlyPubkey = pubKeyBuffer.slice(1, 33);
203+
const myXOnlyPubkey = ecPair.publicKey.slice(1, 33);
40204
const commitHash = bitcoin.crypto.taggedHash('TapTweak', myXOnlyPubkey);
41205
const tweakResult = ecc.xOnlyPointAddTweak(myXOnlyPubkey, commitHash);
42206
if (tweakResult === null) {
@@ -50,30 +214,57 @@ export function p2trAddressFromPublicKey(
50214
tweaked,
51215
]);
52216

53-
const address = bitcoin.address.fromOutputScript(scriptPubkey, BITCOIN_NETWORKS[network]);
54-
return address;
217+
const address = bitcoin.address.fromOutputScript(scriptPubkey, network);
218+
return { ecPair, address };
55219
}
56220

57-
export function p2trAddressFromPrivateKey(
58-
privateKey: Buffer,
59-
network: keyof typeof BITCOIN_NETWORKS
60-
): string {
61-
const ecPair = ECPair.fromPrivateKey(privateKey, { compressed: true });
62-
if (!ecPair.publicKey) {
63-
throw new Error(`Could not get public key`);
221+
export interface VerboseKeyOutput {
222+
address: string;
223+
wif: string;
224+
privateKey: Buffer;
225+
publicKey: Buffer;
226+
}
227+
228+
export function getBitcoinAddressFromKey<TVerbose extends boolean = false>(
229+
args: KeyInputArgs & {
230+
addressFormat: 'p2pkh' | 'p2sh' | 'p2sh-p2wpkh' | 'p2sh-p2wsh' | 'p2wpkh' | 'p2wsh' | 'p2tr';
231+
verbose?: TVerbose;
232+
}
233+
): TVerbose extends true ? VerboseKeyOutput : string {
234+
const keyOutput: KeyOutput = (() => {
235+
switch (args.addressFormat) {
236+
case 'p2pkh':
237+
return p2pkhAddressFromKey(args);
238+
case 'p2sh':
239+
return p2shAddressFromKey(args);
240+
case 'p2sh-p2wpkh':
241+
return p2shp2wpkhAddressFromKey(args);
242+
case 'p2sh-p2wsh':
243+
return p2shp2wshAddressFromKeys(args);
244+
case 'p2wpkh':
245+
return p2wpkhAddressFromKey(args);
246+
case 'p2wsh':
247+
return p2wshAddressFromKey(args);
248+
case 'p2tr':
249+
return p2trAddressFromKey(args);
250+
}
251+
throw new Error(`Unexpected address format: ${args.addressFormat}`);
252+
})();
253+
254+
if (args.verbose) {
255+
const output: VerboseKeyOutput = {
256+
address: keyOutput.address,
257+
wif: keyOutput.ecPair.toWIF(),
258+
privateKey: keyOutput.ecPair.privateKey as Buffer,
259+
publicKey: keyOutput.ecPair.publicKey,
260+
};
261+
return output as TVerbose extends true ? VerboseKeyOutput : string;
262+
} else {
263+
return keyOutput.address as TVerbose extends true ? VerboseKeyOutput : string;
64264
}
65-
return p2trAddressFromPublicKey(ecPair.publicKey, network);
66265
}
67266

68-
export function generateRandomP2TRAccount(
69-
network: keyof typeof BITCOIN_NETWORKS
70-
): {
71-
address: string;
72-
privateKey: Buffer;
73-
} {
74-
const ecPair = ECPair.makeRandom({ compressed: true });
75-
return {
76-
address: p2trAddressFromPublicKey(ecPair.publicKey, network),
77-
privateKey: ecPair.privateKey as Buffer,
78-
};
267+
export function privateToPublicKey(privateKey: string | Buffer): Buffer {
268+
const ecPair = ecPairFromKeyInputArgs({ privateKey, network: 'mainnet' });
269+
return ecPair.publicKey;
79270
}

src/helpers.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from 'winston/lib/winston/config';
2626
import { DbEventTypeId, DbStxEvent, DbTx } from './datastore/common';
2727
import { StacksCoreRpcClient } from './core-rpc/client';
28+
import { isArrayBufferView } from 'node:util/types';
2829

2930
export const isDevEnv = process.env.NODE_ENV === 'development';
3031
export const isTestEnv = process.env.NODE_ENV === 'test';
@@ -525,6 +526,31 @@ export function hexToBuffer(hex: string): Buffer {
525526
return Buffer.from(hex.substring(2), 'hex');
526527
}
527528

529+
/**
530+
* Decodes a hex string to a Buffer, trims the 0x-prefix if exists.
531+
* If already a buffer, returns the input immediately.
532+
*/
533+
export function coerceToBuffer(hex: string | Buffer | ArrayBufferView): Buffer {
534+
if (typeof hex === 'string') {
535+
if (hex.startsWith('0x')) {
536+
hex = hex.substring(2);
537+
}
538+
if (hex.length % 2 !== 0) {
539+
throw new Error(`Hex string is an odd number of characters: ${hex}`);
540+
}
541+
if (!/^[0-9a-fA-F]*$/.test(hex)) {
542+
throw new Error(`Hex string contains non-hexadecimal characters: ${hex}`);
543+
}
544+
return Buffer.from(hex, 'hex');
545+
} else if (Buffer.isBuffer(hex)) {
546+
return hex;
547+
} else if (isArrayBufferView(hex)) {
548+
return Buffer.from(hex.buffer, hex.byteOffset, hex.byteLength);
549+
} else {
550+
throw new Error(`Cannot convert to Buffer, unexpected type: ${hex.constructor.name}`);
551+
}
552+
}
553+
528554
export function hexToUtf8String(hex: string): string {
529555
const buffer = hexToBuffer(hex);
530556
return buffer.toString('utf8');

0 commit comments

Comments
 (0)