Skip to content

Commit

Permalink
feat: match relevant Trezor test vectors
Browse files Browse the repository at this point in the history
  • Loading branch information
sneurlax committed Dec 24, 2024
1 parent c9ab71a commit b966379
Show file tree
Hide file tree
Showing 4 changed files with 350 additions and 17 deletions.
6 changes: 0 additions & 6 deletions example/bip45_example.dart

This file was deleted.

72 changes: 72 additions & 0 deletions example/bip48_example.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import 'package:bip48/bip48.dart';
import 'package:coinlib/coinlib.dart';

void main() async {
// Initialize coinlib.
await loadCoinlib();

// Example 1: Create a 2-of-3 P2SH multisig wallet using xpubs.
print('\nExample 1: 2-of-3 P2SH multisig from xpubs');
print('===============================================');

// These xpubs are from Trezor's test vectors.
//
// See https://github.com/trezor/trezor-firmware/blob/f10dc86da21734fd7be36bbd269da112747df1f3/tests/device_tests/bitcoin/test_getaddress_show.py#L177.
final cosignerXpubs = [
"xpub6EgGHjcvovyMw8xyoJw9ZRUfjGLS1KUmbjVqMKSNfM6E8hq4EbQ3CpBxfGCPsdxzXtCFuKCxYarzY1TYCG1cmPwq9ep548cM9Ws9rB8V8E8",
"xpub6EexEtC6c2rN5QCpzrL2nUNGDfxizCi3kM1C2Mk5a6PfQs4H3F72C642M3XbnzycvvtD4U6vzn1nYPpH8VUmiREc2YuXP3EFgN1uLTrVEj4",
"xpub6F6Tq7sVLDrhuV3SpvsVKrKofF6Hx7oKxWLFkN6dbepuMhuYueKUnQo7E972GJyeRHqPKu44V1C9zBL6KW47GXjuprhbNrPQahWAFKoL2rN",
];

// Create wallet with first xpub.
final xpubWallet = Bip48Wallet(
accountXpub: cosignerXpubs[0],
coinType: 0, // Bitcoin mainnet.
account: 0,
scriptType: Bip48ScriptType.p2shMultisig,
threshold: 2, // 2-of-3 multisig.
totalKeys: 3,
);

// Add other cosigner xpubs.
xpubWallet.addCosignerXpub(cosignerXpubs[1]);
xpubWallet.addCosignerXpub(cosignerXpubs[2]);

// Generate first receiving address.
final address0 = xpubWallet.deriveMultisigAddress(0, isChange: false);
print('First receiving address: $address0');

// Generate first change address.
final change0 = xpubWallet.deriveMultisigAddress(0, isChange: true);
print('First change address: $change0');

// Example 2: Create wallet from master private key.
print('\nExample 2: P2SH multisig from master key');
print('========================================');

// Create from a test seed.
final seedHex = "000102030405060708090a0b0c0d0e0f";
final masterKey = HDPrivateKey.fromSeed(hexToBytes(seedHex));

final privWallet = Bip48Wallet(
masterKey: masterKey,
coinType: 0,
account: 0,
scriptType: Bip48ScriptType.p2shMultisig,
threshold: 2,
totalKeys: 3,
);

// Get the account xpub for sharing with cosigners.
print('Account xpub to share: ${privWallet.accountXpub}');

// Add cosigner xpubs (using example xpubs from above).
privWallet.addCosignerXpub(cosignerXpubs[1]);
privWallet.addCosignerXpub(cosignerXpubs[2]);

// Generate addresses.
print(
'First receiving address: ${privWallet.deriveMultisigAddress(0, isChange: false)}');
print(
'First change address: ${privWallet.deriveMultisigAddress(0, isChange: true)}');
}
182 changes: 177 additions & 5 deletions lib/bip48.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,180 @@
/// Support for doing something awesome.
import 'package:bip48/src/networks/bitcoin.dart';
import 'package:coinlib/coinlib.dart';

/// BIP48 script types.
enum Bip48ScriptType {
p2shMultisig,
p2shP2wshMultisig,
p2wshMultisig,
}

/// Generate
///
/// More dartdocs go here.
library;
/// Returns "m/48'/coin_type'/account'/script_index'".
String bip48DerivationPath({
required int coinType,
required int account,
required Bip48ScriptType scriptType,
}) {
final scriptIndex = switch (scriptType) {
Bip48ScriptType.p2shMultisig => 0,
Bip48ScriptType.p2shP2wshMultisig => 1,
Bip48ScriptType.p2wshMultisig => 2,
};
return "m/48'/$coinType'/$account'/$scriptIndex'";
}

/// A BIP48 wallet that can do M-of-N multisig derivation, either with a
/// private master (so you can sign) or public-only. The underlying
/// coinlib HDKey code uses .derive(...) for child derivation.
class Bip48Wallet {
HDPrivateKey? _accountPrivKey;
HDPublicKey? _accountPubKey;

final int coinType;
final int account;
final Bip48ScriptType scriptType;
final int threshold;
final int totalKeys;

final List<HDPublicKey> cosignerKeys = [];

bool get canSign => _accountPrivKey != null;

Bip48Wallet({
HDPrivateKey? masterKey,
String? accountXpub,
required this.coinType,
required this.account,
required this.scriptType,
required this.threshold,
required this.totalKeys,
}) {
if (threshold < 1 || threshold > totalKeys) {
throw ArgumentError(
"Invalid threshold=$threshold for totalKeys=$totalKeys");
}
if (masterKey == null && accountXpub == null) {
throw ArgumentError("Provide either masterKey or accountXpub.");
}

final path = bip48DerivationPath(
coinType: coinType,
account: account,
scriptType: scriptType,
);

if (masterKey != null) {
final acctKey = masterKey.derivePath(path) as HDPrivateKey;
_accountPrivKey = acctKey;
cosignerKeys.add(acctKey.hdPublicKey);
} else {
final pub = HDPublicKey.decode(accountXpub!);
_accountPubKey = pub;
cosignerKeys.add(pub);
}
}

/// Return the xpub for this account.
String get accountXpub {
if (_accountPrivKey != null) {
final pub = _accountPrivKey!.hdPublicKey;
return pub.encode(bitcoinNetwork.mainnet.pubHDPrefix);
} else {
return _accountPubKey!.encode(bitcoinNetwork.mainnet.pubHDPrefix);
}
}

/// Add another cosigner xpub to form the M-of-N set.
void addCosignerXpub(String xpub) {
final pub = HDPublicKey.decode(xpub);
cosignerKeys.add(pub);
}

/// Derive a child public key for the [addressIndex]. BIP48 typically
/// uses non-hardened for change=0/1 and address indices (so pass
/// an integer < HDKey.hardenBit).
HDPublicKey deriveChildPublicKey(int addressIndex, {required bool isChange}) {
final changeIndex = isChange ? 1 : 0;
if (changeIndex >= HDKey.hardenBit) {
throw ArgumentError("changeIndex must be < 0x80000000 (non-hardened).");
}
if (addressIndex >= HDKey.hardenBit) {
throw ArgumentError("addressIndex must be < 0x80000000 (non-hardened).");
}

if (_accountPrivKey != null) {
final HDPrivateKey step1 = _accountPrivKey!.derive(changeIndex);
final HDPrivateKey step2 = step1.derive(addressIndex);
return step2.hdPublicKey;
} else {
// We only have the public key, so we can only do non-hardened derivation.
final HDKey step1 = _accountPubKey!.derive(changeIndex);
final HDKey step2 = step1.derive(addressIndex);
return step2 as HDPublicKey;
}
}

/// Derive a multi-sig address from all cosigners for the given index.
String deriveMultisigAddress(int addressIndex, {required bool isChange}) {
if (cosignerKeys.length < totalKeys) {
throw StateError(
"Not enough cosigners added (${cosignerKeys.length} < $totalKeys)");
}

if (cosignerKeys.length < totalKeys) {
throw StateError(
"Not enough cosigners added (${cosignerKeys.length} < $totalKeys)");
}

final childKeys = <ECPublicKey>[];
final cIndex = isChange ? 1 : 0;

if (cIndex >= HDKey.hardenBit) {
throw ArgumentError("change index must be < 0x80000000");
}
if (addressIndex >= HDKey.hardenBit) {
throw ArgumentError("address index must be < 0x80000000");
}

// Derive child keys, maintaining original order.
//
// Originally, child keys were sorted according to BIP67. However, this
// broke the tests, so we use the original order here in order to strictly
// adhere to Trezor's vectors.
for (final cosigner in cosignerKeys) {
final step1 = cosigner.derive(cIndex);
final step2 = step1.derive(addressIndex);
final cPub = (step2 as HDPublicKey).publicKey;
childKeys.add(cPub);
}

final script = Script([
ScriptOp.fromNumber(threshold),
...childKeys.map((pk) => ScriptPushData(pk.data)),
ScriptOp.fromNumber(childKeys.length),
ScriptOpCode.fromName("CHECKMULTISIG"),
]);

switch (scriptType) {
case Bip48ScriptType.p2shMultisig:
return P2SHAddress.fromRedeemScript(
script,
version: bitcoinNetwork.mainnet.p2shPrefix,
).toString();

export 'src/bip48_base.dart';
case Bip48ScriptType.p2shP2wshMultisig:
final witnessProg = P2WSH.fromWitnessScript(script);
return P2SHAddress.fromRedeemScript(
witnessProg.script,
version: bitcoinNetwork.mainnet.p2shPrefix,
).toString();

// TODO: Export any libraries intended for clients of this package.
case Bip48ScriptType.p2wshMultisig:
return P2WSHAddress.fromWitnessScript(
script,
hrp: bitcoinNetwork.mainnet.bech32Hrp,
).toString();
}
}
}
107 changes: 101 additions & 6 deletions test/bip48_test.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,111 @@
import 'package:bip48/bip48.dart';
import 'package:coinlib/coinlib.dart';
import 'package:test/test.dart';

void main() {
group('A group of tests', () {
final awesome = Awesome();
group("BIP48 P2SH Multisig Tests", () {
setUpAll(() async {
await loadCoinlib();
});

test("Trezor test vector - P2SH 2-of-3 multisig", () {
// From Trezor test vectors.
//
// See https://github.com/trezor/trezor-firmware/blob/f10dc86da21734fd7be36bbd269da112747df1f3/tests/device_tests/bitcoin/test_getaddress_show.py#L177.
final pubkeys = [
"xpub6EgGHjcvovyMw8xyoJw9ZRUfjGLS1KUmbjVqMKSNfM6E8hq4EbQ3CpBxfGCPsdxzXtCFuKCxYarzY1TYCG1cmPwq9ep548cM9Ws9rB8V8E8",
"xpub6EexEtC6c2rN5QCpzrL2nUNGDfxizCi3kM1C2Mk5a6PfQs4H3F72C642M3XbnzycvvtD4U6vzn1nYPpH8VUmiREc2YuXP3EFgN1uLTrVEj4",
"xpub6F6Tq7sVLDrhuV3SpvsVKrKofF6Hx7oKxWLFkN6dbepuMhuYueKUnQo7E972GJyeRHqPKu44V1C9zBL6KW47GXjuprhbNrPQahWAFKoL2rN",
];

final wallet = Bip48Wallet(
accountXpub: pubkeys[0], // Start with first key.
coinType: 0, // Bitcoin mainnet.
account: 0, // First account.
scriptType: Bip48ScriptType.p2shMultisig,
threshold: 2, // 2-of-3.
totalKeys: 3,
);

// Add other two cosigner xpubs.
wallet.addCosignerXpub(pubkeys[1]);
wallet.addCosignerXpub(pubkeys[2]);

setUp(() {
// Additional setup goes here.
// Test external (non-change) address at index 0.
expect(
wallet.deriveMultisigAddress(0, isChange: false),
"33TU5DyVi2kFSGQUfmZxNHgPDPqruwdesY",
);
});

test('First Test', () {
expect(awesome.isAwesome, isTrue);
test("Path validation and derivation", () {
final seedHex = "000102030405060708090a0b0c0d0e0f";
final masterKey = HDPrivateKey.fromSeed(hexToBytes(seedHex));

final path = bip48DerivationPath(
coinType: 0,
account: 0,
scriptType: Bip48ScriptType.p2shMultisig,
);
expect(path, "m/48'/0'/0'/0'");

// Verify we can derive through this path
final wallet = Bip48Wallet(
masterKey: masterKey,
coinType: 0,
account: 0,
scriptType: Bip48ScriptType.p2shMultisig,
threshold: 2,
totalKeys: 3,
);

expect(wallet.canSign, true);

// Add the two cosigners from Trezor's test vector.
wallet.addCosignerXpub(
"xpub6EexEtC6c2rN5QCpzrL2nUNGDfxizCi3kM1C2Mk5a6PfQs4H3F72C642M3XbnzycvvtD4U6vzn1nYPpH8VUmiREc2YuXP3EFgN1uLTrVEj4");
wallet.addCosignerXpub(
"xpub6F6Tq7sVLDrhuV3SpvsVKrKofF6Hx7oKxWLFkN6dbepuMhuYueKUnQo7E972GJyeRHqPKu44V1C9zBL6KW47GXjuprhbNrPQahWAFKoL2rN");

// Now test address derivation.
expect(
() => wallet.deriveMultisigAddress(HDKey.hardenBit, isChange: false),
throwsArgumentError,
);
expect(
() => wallet.deriveMultisigAddress(0, isChange: true),
returnsNormally,
);
});

test("Wallet construction validation", () {
final seedHex = "000102030405060708090a0b0c0d0e0f";
final masterKey = HDPrivateKey.fromSeed(hexToBytes(seedHex));

// Invalid M-of-N.
expect(
() => Bip48Wallet(
masterKey: masterKey,
coinType: 0,
account: 0,
scriptType: Bip48ScriptType.p2shMultisig,
threshold: 4, // Can't have 4-of-3.
totalKeys: 3,
),
throwsArgumentError,
);

// Must provide either master key or xpub.
expect(
() => Bip48Wallet(
coinType: 0,
account: 0,
scriptType: Bip48ScriptType.p2shMultisig,
threshold: 2,
totalKeys: 3,
),
throwsArgumentError,
);
});
});
}

0 comments on commit b966379

Please sign in to comment.