diff --git a/lib/src/payment_address.dart b/lib/src/payment_address.dart index 0ae7ec3..62b2e7f 100644 --- a/lib/src/payment_address.dart +++ b/lib/src/payment_address.dart @@ -48,6 +48,13 @@ class PaymentAddress { Uint8List hashSharedSecret() => SHA256Digest().process(getSharedSecret().ecdhSecret()); + /// Returns the compressed public key bytes for the derived send address. + /// Can be used by callers to construct any address type (P2PKH, P2WPKH, P2TR). + Uint8List getDerivedSendPublicKey() { + final sum = getECPoint() + sG(); + return sum!.getEncoded(true); + } + bitcoindart.ECPair _getSendAddressKeyPair() { final sum = getECPoint() + sG(); return bitcoindart.ECPair.fromPublicKey( @@ -111,6 +118,13 @@ class PaymentAddress { return p2pkh.data.address!; } + /// Returns the compressed public key bytes for the derived receive address. + /// Can be used by callers to construct any address type (P2PKH, P2WPKH, P2TR). + Uint8List getDerivedReceivePublicKey() { + final pair = getReceiveAddressKeyPair(); + return pair.publicKey; + } + BigInt _addSecp256k1(BigInt b1, BigInt b2) { final BigInt value = b1 + b2; diff --git a/lib/src/payment_code.dart b/lib/src/payment_code.dart index bf8489f..fdc8b03 100644 --- a/lib/src/payment_code.dart +++ b/lib/src/payment_code.dart @@ -20,6 +20,7 @@ class PaymentCode { static const int SAMOURAI_FEATURE_BYTE = 79; static const int SAMOURAI_SEGWIT_BIT = 0; + static const int SAMOURAI_TAPROOT_BIT = 1; late String _paymentCodeString; late final bip32.BIP32 _bip32Node; @@ -65,13 +66,22 @@ class PaymentCode { _getBip32NetworkTypeFrom(networkType), ); - final bool isSamouraiSegwit = Util.isBitSet( + final bool hasTaproot = Util.isBitSet( + payload[SAMOURAI_FEATURE_BYTE], + SAMOURAI_TAPROOT_BIT, + ); + final bool hasSegwit = Util.isBitSet( payload[SAMOURAI_FEATURE_BYTE], SAMOURAI_SEGWIT_BIT, ); - _paymentCodeString = - isSamouraiSegwit ? _makeSamouraiPaymentCode() : _makeV1(); + if (hasTaproot) { + _paymentCodeString = _makeTaprootPaymentCode(); + } else if (hasSegwit) { + _paymentCodeString = _makeSamouraiPaymentCode(); + } else { + _paymentCodeString = _makeV1(); + } } // initialize payment code given a bip32 object @@ -79,6 +89,7 @@ class PaymentCode { bip32.BIP32 bip32Node, { required this.networkType, required bool shouldSetSegwitBit, + bool shouldSetTaprootBit = false, }) { if (bip32Node.network.wif != networkType.wif || bip32Node.network.bip32.public != networkType.bip32.public || @@ -87,9 +98,12 @@ class PaymentCode { "BIP32 network info does not match provided networkType info"); } _bip32Node = bip32Node; + // Always build v1 first (sets _paymentCodeString), then upgrade if needed. _paymentCodeString = _makeV1(); - if (shouldSetSegwitBit) { + if (shouldSetTaprootBit) { + _paymentCodeString = _makeTaprootPaymentCode(); + } else if (shouldSetSegwitBit) { _paymentCodeString = _makeSamouraiPaymentCode(); } } @@ -131,6 +145,11 @@ class PaymentCode { SAMOURAI_SEGWIT_BIT, ); + bool isTaprootEnabled() => Util.isBitSet( + getPayload()[SAMOURAI_FEATURE_BYTE], + SAMOURAI_TAPROOT_BIT, + ); + Uint8List getPubKey() => _bip32Node.publicKey; Uint8List getChain() => _bip32Node.chainCode; @@ -268,6 +287,20 @@ class PaymentCode { return paymentCode.toBase58Check; } + String _makeTaprootPaymentCode() { + final payload = getPayload(); + // set segwit bit (taproot implies segwit) + payload[SAMOURAI_FEATURE_BYTE] = + Util.setBit(payload[SAMOURAI_FEATURE_BYTE], SAMOURAI_SEGWIT_BIT); + // set taproot bit + payload[SAMOURAI_FEATURE_BYTE] = + Util.setBit(payload[SAMOURAI_FEATURE_BYTE], SAMOURAI_TAPROOT_BIT); + Uint8List paymentCode = Uint8List(PAYLOAD_LEN + 1); + paymentCode[0] = 0x47; + Util.copyBytes(payload, 0, paymentCode, 1, PAYLOAD_LEN); + return paymentCode.toBase58Check; + } + /// Use at own risk. Not fully tested bool isValid() { try { diff --git a/test/taproot_test.dart b/test/taproot_test.dart new file mode 100644 index 0000000..1f4d1b9 --- /dev/null +++ b/test/taproot_test.dart @@ -0,0 +1,233 @@ +import 'package:bip32/bip32.dart' as bip32; +import 'package:bip39/bip39.dart' as bip39; +import 'package:bip47/bip47.dart'; +import 'package:bitcoindart/bitcoindart.dart' as bitcoindart; +import 'package:test/test.dart'; + +const String kPath = "m/47'/0'/0'"; + +const String kSeedAlice = + "response seminar brave tip suit recall often sound stick owner lottery mot" + "ion"; +const String kSeedBob = + "reward upper indicate eight swift arch injury crystal super wrestle alread" + "y dentist"; + +bip32.NetworkType get btcNetwork => bip32.NetworkType( + wif: bitcoindart.bitcoin.wif, + bip32: bip32.Bip32Type( + public: bitcoindart.bitcoin.bip32.public, + private: bitcoindart.bitcoin.bip32.private, + ), + ); + +void main() { + late bip32.BIP32 aliceNode; + late bip32.BIP32 bobNode; + + setUpAll(() { + final aliceSeed = bip39.mnemonicToSeed(kSeedAlice); + final aliceRoot = bip32.BIP32.fromSeed(aliceSeed, btcNetwork); + aliceNode = aliceRoot.derivePath(kPath); + + final bobSeed = bip39.mnemonicToSeed(kSeedBob); + final bobRoot = bip32.BIP32.fromSeed(bobSeed, btcNetwork); + bobNode = bobRoot.derivePath(kPath); + }); + + group('PaymentCode taproot feature bit', () { + test('fromBip32Node with shouldSetTaprootBit sets both bits', () { + final code = PaymentCode.fromBip32Node( + aliceNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: true, + shouldSetTaprootBit: true, + ); + expect(code.isTaprootEnabled(), isTrue); + expect(code.isSegWitEnabled(), isTrue); + }); + + test('fromBip32Node without taproot does not set taproot bit', () { + final code = PaymentCode.fromBip32Node( + aliceNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: true, + ); + expect(code.isTaprootEnabled(), isFalse); + expect(code.isSegWitEnabled(), isTrue); + }); + + test('fromBip32Node with neither flag sets neither bit', () { + final code = PaymentCode.fromBip32Node( + aliceNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: false, + ); + expect(code.isTaprootEnabled(), isFalse); + expect(code.isSegWitEnabled(), isFalse); + }); + + test('round-trip: encode taproot code, parse back, verify bits', () { + final original = PaymentCode.fromBip32Node( + aliceNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: true, + shouldSetTaprootBit: true, + ); + final codeString = original.toString(); + + final parsed = PaymentCode.fromPaymentCode( + codeString, + networkType: bitcoindart.bitcoin, + ); + expect(parsed.isTaprootEnabled(), isTrue); + expect(parsed.isSegWitEnabled(), isTrue); + }); + + test('round-trip: segwit-only code preserved', () { + final original = PaymentCode.fromBip32Node( + aliceNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: true, + ); + final parsed = PaymentCode.fromPaymentCode( + original.toString(), + networkType: bitcoindart.bitcoin, + ); + expect(parsed.isSegWitEnabled(), isTrue); + expect(parsed.isTaprootEnabled(), isFalse); + }); + + test('taproot and non-taproot codes have same notification address', () { + final taproot = PaymentCode.fromBip32Node( + aliceNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: true, + shouldSetTaprootBit: true, + ); + final v1 = PaymentCode.fromBip32Node( + aliceNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: false, + ); + expect( + taproot.notificationAddressP2PKH(), + equals(v1.notificationAddressP2PKH()), + ); + }); + }); + + group('PaymentAddress derived public keys', () { + test('getDerivedSendPublicKey returns 33-byte compressed key', () { + final bobCode = PaymentCode.fromBip32Node( + bobNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: false, + ); + + final paymentAddress = PaymentAddress( + bip32Node: aliceNode.derive(0), + paymentCode: bobCode, + networkType: bitcoindart.bitcoin, + index: 0, + ); + + final pubKey = paymentAddress.getDerivedSendPublicKey(); + expect(pubKey.length, equals(33)); + expect(pubKey[0] == 0x02 || pubKey[0] == 0x03, isTrue); + }); + + test( + 'send and receive derive same public key ' + '(Alice sends to Bob, Bob receives from Alice)', () { + final aliceCode = PaymentCode.fromBip32Node( + aliceNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: false, + ); + final bobCode = PaymentCode.fromBip32Node( + bobNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: false, + ); + + // Alice sends to Bob: uses Alice's private key + Bob's public code + final aliceSendAddr = PaymentAddress( + bip32Node: aliceNode.derive(0), + paymentCode: bobCode, + networkType: bitcoindart.bitcoin, + index: 0, + ); + + // Bob receives from Alice: uses Bob's private key + Alice's public code + final bobReceiveAddr = PaymentAddress( + bip32Node: bobNode.derive(0), + paymentCode: aliceCode, + networkType: bitcoindart.bitcoin, + index: 0, + ); + + final sendPubKey = aliceSendAddr.getDerivedSendPublicKey(); + final receivePubKey = bobReceiveAddr.getDerivedReceivePublicKey(); + + expect(sendPubKey, equals(receivePubKey)); + }); + + test('getDerivedSendPublicKey matches P2PKH address derivation', () { + final bobCode = PaymentCode.fromBip32Node( + bobNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: false, + ); + + final paymentAddress = PaymentAddress( + bip32Node: aliceNode.derive(0), + paymentCode: bobCode, + networkType: bitcoindart.bitcoin, + index: 0, + ); + + // The P2PKH address should correspond to the derived public key + final p2pkhAddress = paymentAddress.getSendAddressP2PKH(); + final derivedPubKey = paymentAddress.getDerivedSendPublicKey(); + + // Verify by constructing P2PKH from the derived key directly + final pair = bitcoindart.ECPair.fromPublicKey( + derivedPubKey, + network: bitcoindart.bitcoin, + ); + final p2pkh = bitcoindart.P2PKH( + data: bitcoindart.PaymentData(pubkey: pair.publicKey), + network: bitcoindart.bitcoin, + ); + + expect(p2pkh.data.address, equals(p2pkhAddress)); + }); + + test('derived keys at different indices produce different addresses', () { + final bobCode = PaymentCode.fromBip32Node( + bobNode, + networkType: bitcoindart.bitcoin, + shouldSetSegwitBit: false, + ); + + final addr0 = PaymentAddress( + bip32Node: aliceNode.derive(0), + paymentCode: bobCode, + networkType: bitcoindart.bitcoin, + index: 0, + ); + final addr1 = PaymentAddress( + bip32Node: aliceNode.derive(0), + paymentCode: bobCode, + networkType: bitcoindart.bitcoin, + index: 1, + ); + + expect( + addr0.getDerivedSendPublicKey(), + isNot(equals(addr1.getDerivedSendPublicKey())), + ); + }); + }); +}