diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index ca89d88213..fd5be5e448 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -189,4 +189,11 @@ xcodeproj xctest xctestrun xcworkspace -yoroi \ No newline at end of file +yoroi +multiplatform +Multiplatform +Easterling +lovelace +lovelaces +pinenacl +dtscalac diff --git a/catalyst_voices/pubspec.yaml b/catalyst_voices/pubspec.yaml index 15c94c316a..810aaed179 100644 --- a/catalyst_voices/pubspec.yaml +++ b/catalyst_voices/pubspec.yaml @@ -10,6 +10,8 @@ environment: dependencies: animated_text_kit: ^4.2.2 animations: ^2.0.11 + catalyst_cardano_serialization: + path: ../catalyst_voices_packages/catalyst_cardano_serialization catalyst_voices_assets: path: ./packages/catalyst_voices_assets catalyst_voices_blocs: diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/CHANGELOG.md b/catalyst_voices_packages/catalyst_cardano_serialization/CHANGELOG.md new file mode 100644 index 0000000000..a533b45bb9 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/CHANGELOG.md @@ -0,0 +1,3 @@ +# 0.1.0 + +* Initial release. diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/README.md b/catalyst_voices_packages/catalyst_cardano_serialization/README.md new file mode 100644 index 0000000000..191e021c7c --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/README.md @@ -0,0 +1 @@ +# catalyst_cardano_serialization diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/analysis_options.yaml b/catalyst_voices_packages/catalyst_cardano_serialization/analysis_options.yaml new file mode 100644 index 0000000000..886855b51a --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:catalyst_analysis/analysis_options.1.0.0.yaml + +analyzer: + exclude: [build/**, lib/*.g.dart, lib/generated/**] diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/catalyst_cardano_serialization.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/catalyst_cardano_serialization.dart new file mode 100644 index 0000000000..35d28db13b --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/catalyst_cardano_serialization.dart @@ -0,0 +1,6 @@ +export 'src/address.dart'; +export 'src/exceptions.dart'; +export 'src/fees.dart'; +export 'src/hashes.dart'; +export 'src/transaction.dart'; +export 'src/types.dart'; diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/address.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/address.dart new file mode 100644 index 0000000000..a0aefa1477 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/address.dart @@ -0,0 +1,128 @@ +// Copyright 2021 Richard Easterling +// SPDX-License-Identifier: Apache-2.0 + +// ignore_for_file: avoid_equals_and_hash_code_on_mutable_classes + +import 'package:bip32_ed25519/bip32_ed25519.dart'; +import 'package:catalyst_cardano_serialization/src/exceptions.dart'; +import 'package:catalyst_cardano_serialization/src/types.dart'; +import 'package:cbor/cbor.dart'; + +/// [ShelleyAddress] supports bech32 encoded addresses as defined in CIP19. +class ShelleyAddress { + /// The prefix of a base address. + static const String defaultAddrHrp = 'addr'; + + /// The prefix of a stake/reward address. + static const String defaultRewardHrp = 'stake'; + + /// The hrp suffix of an address on testnet network. + static const String testnetHrpSuffix = '_test'; + + static const Bech32Encoder _mainNetEncoder = + Bech32Encoder(hrp: defaultAddrHrp); + static const Bech32Encoder _testNetEncoder = + Bech32Encoder(hrp: defaultAddrHrp + testnetHrpSuffix); + static const Bech32Encoder _mainNetRewardEncoder = + Bech32Encoder(hrp: defaultRewardHrp); + static const Bech32Encoder _testNetRewardEncoder = + Bech32Encoder(hrp: defaultRewardHrp + testnetHrpSuffix); + + /// Raw bytes of address. + /// Format [ 8 bit header | payload ] + final Uint8List bytes; + + /// The prefix specifying the address type and networkId. + final String hrp; + + /// The constructor for [ShelleyAddress] from raw [bytes] and [hrp]. + ShelleyAddress(List bytes, {this.hrp = defaultAddrHrp}) + : bytes = Uint8List.fromList(bytes); + + /// The constructor which parses the address from bech32 format. + factory ShelleyAddress.fromBech32(String address) { + final hrp = _hrpPrefix(address); + if (hrp.isEmpty) { + throw InvalidAddressException( + 'not a valid Bech32 address - no prefix: $address', + ); + } + + switch (hrp) { + case defaultAddrHrp: + return ShelleyAddress(_mainNetEncoder.decode(address), hrp: hrp); + case const (defaultAddrHrp + testnetHrpSuffix): + return ShelleyAddress(_testNetEncoder.decode(address), hrp: hrp); + case defaultRewardHrp: + return ShelleyAddress(_mainNetRewardEncoder.decode(address), hrp: hrp); + case const (defaultRewardHrp + testnetHrpSuffix): + return ShelleyAddress(_testNetRewardEncoder.decode(address), hrp: hrp); + default: + return ShelleyAddress( + Bech32Encoder(hrp: hrp).decode(address), + hrp: hrp, + ); + } + } + + /// Returns the [NetworkId] related to this address. + NetworkId get network => NetworkId.testnet.id == (bytes[0] & 0x0f) + ? NetworkId.testnet + : NetworkId.mainnet; + + /// Encodes the address in bech32 format. + String toBech32() { + final prefix = _computeHrp(network, hrp); + switch (prefix) { + case defaultAddrHrp: + return _mainNetEncoder.encode(bytes); + case const (defaultAddrHrp + testnetHrpSuffix): + return _testNetEncoder.encode(bytes); + case defaultRewardHrp: + return _mainNetRewardEncoder.encode(bytes); + case const (defaultRewardHrp + testnetHrpSuffix): + return _testNetRewardEncoder.encode(bytes); + default: + return Bech32Encoder(hrp: prefix).encode(bytes); + } + } + + /// Serializes the type as cbor. + CborValue toCbor() => CborBytes(bytes); + + @override + int get hashCode => Object.hash(bytes, hrp); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! ShelleyAddress) return false; + if (bytes.length != other.bytes.length) return false; + if (hrp != other.hrp) return false; + + for (var i = 0; i < bytes.length; i++) { + if (bytes[i] != other.bytes[i]) return false; + } + return true; + } + + @override + String toString() => toBech32(); + + /// If were using the testnet, make sure the hrp ends with '_test' + static String _computeHrp(NetworkId id, String prefix) { + if (id == NetworkId.mainnet) { + return prefix; + } else if (prefix.endsWith(testnetHrpSuffix)) { + return prefix; + } else { + return prefix + testnetHrpSuffix; + } + } + + static String _hrpPrefix(String addr) { + final s = addr.trim(); + final i = s.indexOf('1'); + return s.substring(0, i > 0 ? i : 0); + } +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/exceptions.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/exceptions.dart new file mode 100644 index 0000000000..96bf101459 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/exceptions.dart @@ -0,0 +1,51 @@ +/// Exception thrown when the transaction exceeds the allowed maximum size. +final class MaxTxSizeExceededException implements Exception { + /// The maximum amount of bytes per transaction. + final int maxTxSize; + + /// The amount of bytes of transaction that exceeded it's maximum size. + final int actualTxSize; + + /// The default constructor for [MaxTxSizeExceededException]. + const MaxTxSizeExceededException({ + required this.maxTxSize, + required this.actualTxSize, + }); + + @override + String toString() => 'MaxTxSizeExceededException(' + 'maxTxSize=$maxTxSize' + ', actualTxSize:$actualTxSize' + ')'; +} + +/// Exception thrown when building a transaction that doesn't specify the fee. +final class TxFeeNotSpecifiedException implements Exception { + /// The default constructor for [TxFeeNotSpecifiedException]. + const TxFeeNotSpecifiedException(); + + @override + String toString() => 'TxFeeNotSpecifiedException'; +} + +/// Exception thrown when parsing a hash that has incorrect length. +final class HashFormatException implements Exception { + /// The default constructor for [HashFormatException]. + const HashFormatException(); + + @override + String toString() => 'HashFormatException'; +} + +/// Exception thrown if the address doesn't match the bech32 specification +/// for Shelley addresses. +final class InvalidAddressException implements Exception { + /// Exception details. + final String message; + + /// Default constructor [InvalidAddressException]. + const InvalidAddressException(this.message); + + @override + String toString() => 'InvalidAddressException: $message'; +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/fees.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/fees.dart new file mode 100644 index 0000000000..a47471798c --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/fees.dart @@ -0,0 +1,30 @@ +import 'package:catalyst_cardano_serialization/src/transaction.dart'; +import 'package:catalyst_cardano_serialization/src/types.dart'; +import 'package:cbor/cbor.dart'; + +/// Calculates fees for the transaction on Cardano blockchain. +/// +/// The fee is calculated using the following formula: +/// - `fee = constant + tx.bytes.len * coefficient` +final class LinearFee { + /// The constant amount of [Coin] that is charged per transaction. + final Coin constant; + + /// The amount of [Coin] per transaction byte that is charged per transaction. + final Coin coefficient; + + /// The default constructor for the [LinearFee]. + /// The parameters are Cardano protocol parameters. + const LinearFee({ + required this.constant, + required this.coefficient, + }); + + /// Calculates the fee for the transaction denominated in lovelaces. + /// + /// The formula doesn't take into account smart contract scripts. + Coin minNoScriptFee(Transaction tx) { + final bytesCount = cbor.encode(tx.toCbor()).length; + return Coin(bytesCount) * coefficient + constant; + } +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/hashes.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/hashes.dart new file mode 100644 index 0000000000..4c9cbbc86e --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/hashes.dart @@ -0,0 +1,101 @@ +// ignore_for_file: avoid_equals_and_hash_code_on_mutable_classes + +import 'dart:typed_data'; + +import 'package:catalyst_cardano_serialization/src/exceptions.dart'; +import 'package:catalyst_cardano_serialization/src/transaction.dart'; +import 'package:cbor/cbor.dart'; +import 'package:convert/convert.dart'; +import 'package:pinenacl/digests.dart'; + +/// Implements a common base of hash types that holds +/// binary [bytes] of exact [length]. +abstract base class BaseHash { + /// The raw [bytes] of a hash. + final List bytes; + + /// Constructs the [BaseHash] from raw [bytes]. + BaseHash.fromBytes({required this.bytes}) { + if (bytes.length != length) { + throw const HashFormatException(); + } + } + + /// Constructs the [BaseHash] from a hex string representation + /// of [bytes]. + BaseHash.fromHex(String string) : this.fromBytes(bytes: hex.decode(string)); + + /// The expected length of the transaction hash bytes. + int get length; + + /// Serializes the type as cbor. + CborValue toCbor() => CborBytes(bytes); + + /// Returns the hex string representation of [bytes]. + String toHex() => hex.encode(bytes); + + @override + String toString() => toHex(); + + @override + int get hashCode => Object.hash(bytes, length); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! BaseHash) return false; + + // prevent subclasses of different types to be equal to each other, + // even if they hold the same bytes they represent different kinds + if (other.runtimeType != runtimeType) return false; + + if (length != other.length) return false; + + for (var i = 0; i < bytes.length; i++) { + if (bytes[i] != other.bytes[i]) return false; + } + + return true; + } +} + +/// Describes the hash of the transaction which serves as proof +/// of transaction validation. +final class TransactionHash extends BaseHash { + static const int _length = 32; + + /// Constructs the [TransactionHash] from raw [bytes]. + TransactionHash.fromBytes({required super.bytes}) : super.fromBytes(); + + /// Constructs the [TransactionHash] from a hex string representation + /// of [bytes]. + TransactionHash.fromHex(super.string) : super.fromHex(); + + @override + int get length => _length; +} + +/// Describes the hash of auxiliary data which is included +/// in the transaction body. +final class AuxiliaryDataHash extends BaseHash { + static const int _length = 32; + + /// Constructs the [AuxiliaryDataHash] from raw [bytes]. + AuxiliaryDataHash.fromBytes({required super.bytes}) : super.fromBytes(); + + /// Constructs the [AuxiliaryDataHash] from a hex string representation + /// of [bytes]. + AuxiliaryDataHash.fromHex(super.string) : super.fromHex(); + + /// Constructs the [AuxiliaryDataHash] from a [AuxiliaryData]. + AuxiliaryDataHash.fromAuxiliaryData(AuxiliaryData data) + : super.fromBytes( + bytes: Hash.blake2b( + Uint8List.fromList(cbor.encode(data.toCbor())), + digestSize: _length, + ), + ); + + @override + int get length => _length; +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/transaction.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/transaction.dart new file mode 100644 index 0000000000..a087917110 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/transaction.dart @@ -0,0 +1,173 @@ +import 'package:catalyst_cardano_serialization/src/address.dart'; +import 'package:catalyst_cardano_serialization/src/hashes.dart'; +import 'package:catalyst_cardano_serialization/src/types.dart'; +import 'package:cbor/cbor.dart'; + +/// Represents the signed transaction with a list of witnesses +/// which are used to verify the validity of a transaction. +final class Transaction { + /// The transaction body containing the inputs, outputs, fees, etc. + final TransactionBody body; + + /// True if the transaction is valid, false otherwise. + final bool isValid; + + /// The optional transaction metadata. + final AuxiliaryData? auxiliaryData; + + /// The default constructor for the [Transaction]. + const Transaction({ + required this.body, + required this.isValid, + this.auxiliaryData, + }); + + /// Serializes the type as cbor. + CborValue toCbor() { + return CborList([ + body.toCbor(), + // TODO(dtscalac): implement witnesses + CborMap({}), + CborBool(isValid), + (auxiliaryData ?? const AuxiliaryData()).toCbor(), + ]); + } +} + +/// Represents the details of a transaction including inputs, outputs, fee, etc. +/// +/// Does not contain the witnesses which are used to verify the transaction. +final class TransactionBody { + /// The transaction inputs. + final Set inputs; + + /// The transaction outputs. + final List outputs; + + /// The fee for the transaction. + final Coin fee; + + /// The absolute slot value before the tx becomes invalid. + final SlotBigNum? ttl; + + /// The hash of the optional [AuxiliaryData] + /// which is the metadata of the transaction. + final AuxiliaryDataHash? auxiliaryDataHash; + + /// Specifies on which network the code will run. + final NetworkId? networkId; + + /// The default constructor for [TransactionBody]. + const TransactionBody({ + required this.inputs, + required this.outputs, + required this.fee, + this.ttl, + this.auxiliaryDataHash, + this.networkId, + }); + + /// Serializes the type as cbor. + CborValue toCbor() { + return CborMap({ + const CborSmallInt(0): CborList([ + for (final input in inputs) input.toCbor(), + ]), + const CborSmallInt(1): CborList([ + for (final output in outputs) output.toCbor(), + ]), + const CborSmallInt(2): fee.toCbor(), + if (ttl != null) const CborSmallInt(3): ttl!.toCbor(), + }); + } +} + +/// The transaction output of a previous transaction, +/// acts as input for the next transaction. +final class TransactionInput { + /// The hash of the given transaction. + final TransactionHash transactionId; + + /// The index of the utxo in the given transaction. + final int index; + + /// The default constructor for [TransactionInput]. + const TransactionInput({ + required this.transactionId, + required this.index, + }); + + /// Serializes the type as cbor. + CborValue toCbor() { + return CborList([ + transactionId.toCbor(), + CborSmallInt(index), + ]); + } +} + +/// The transaction output which assigns the owner of given address +/// with leftover change from previous transaction. +final class TransactionOutput { + /// The address associated with the transaction. + final ShelleyAddress address; + + /// The leftover change from the previous transaction that can be spent. + final Coin amount; + + /// The default constructor for the [TransactionOutput]. + const TransactionOutput({ + required this.address, + required this.amount, + }); + + /// Serializes the type as cbor. + CborValue toCbor() { + return CborList([ + address.toCbor(), + amount.toCbor(), + ]); + } +} + +/// The UTXO that can be used as an input in a new transaction. +final class TransactionUnspentOutput { + /// The transaction output of a previous transaction, + /// acts as input for the next transaction. + final TransactionInput input; + + /// The transaction output which assigns the owner of given address + /// with leftover change from previous transaction. + final TransactionOutput output; + + /// The default constructor for [TransactionUnspentOutput]. + const TransactionUnspentOutput({ + required this.input, + required this.output, + }); + + /// Serializes the type as cbor. + CborValue toCbor() { + return CborList([ + input.toCbor(), + output.toCbor(), + ]); + } +} + +/// The transaction metadata as a list of key-value pairs (a map). +final class AuxiliaryData { + /// The transaction metadata map. + final Map map; + + /// The default constructor for the [AuxiliaryData]. + const AuxiliaryData({this.map = const {}}); + + /// Serializes the type as cbor. + CborValue toCbor() { + return CborMap( + map, + tags: map.isNotEmpty ? const [] : [CborCustomTags.map], + ); + } +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/types.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/types.dart new file mode 100644 index 0000000000..fbb9571756 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/types.dart @@ -0,0 +1,60 @@ +import 'package:cbor/cbor.dart'; + +/// Specifies on which network the code will run. +enum NetworkId { + /// The production network + mainnet(id: 1), + + /// The test network. + testnet(id: 0); + + /// The magic protocol number acting as the identifier of the network. + final int id; + + const NetworkId({required this.id}); +} + +/// Specifies an amount of ADA in terms of lovelace. +extension type const Coin(int value) { + /// Adds [other] value to this value and returns a new [Coin]. + Coin operator +(Coin other) => Coin(value + other.value); + + /// Subtracts [other] values from this value and returns a new [Coin]. + Coin operator -(Coin other) => Coin(value - other.value); + + /// Multiplies this value by [other] values and returns a new [Coin]. + Coin operator *(Coin other) => Coin(value * other.value); + + /// Divides this value by [other] value without remainder + /// and returns a new [Coin]. + Coin operator ~/(Coin other) => Coin(value ~/ other.value); + + /// Returns true if [value] is greater than [other] value. + bool operator >(Coin other) => value > other.value; + + /// Returns true if [value] is greater than or equal [other] value. + bool operator >=(Coin other) => value > other.value || value == other.value; + + /// Returns true if [value] is smaller than [other] value. + bool operator <(Coin other) => value < other.value; + + /// Returns true if [value] is smaller than or equal [other] value. + bool operator <=(Coin other) => value < other.value || value == other.value; + + /// Serializes the type as cbor. + CborValue toCbor() => CborSmallInt(value); +} + +/// A blockchain slot number. +extension type const SlotBigNum(int value) { + /// Serializes the type as cbor. + CborValue toCbor() => CborSmallInt(value); +} + +/// Holds cbor tags not specified by the official cbor package. +final class CborCustomTags { + const CborCustomTags._(); + + /// A cbor tag describing a key-value pairs data. + static const int map = 259; +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/pubspec.yaml b/catalyst_voices_packages/catalyst_cardano_serialization/pubspec.yaml new file mode 100644 index 0000000000..c6c0c143b1 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/pubspec.yaml @@ -0,0 +1,21 @@ +name: catalyst_cardano_serialization +description: Dart package providing serialization/deserialization for common structures for Cardano blockchain. +repository: https://github.com/input-output-hk/catalyst-voices/tree/main/catalyst_voices_packages/catalyst_cardano_serialization +issue_tracker: https://github.com/input-output-hk/catalyst-voices/issues +topics: [blockchain, cardano, cryptocurrency, wallet] +version: 0.1.0 + +environment: + sdk: ">=3.3.0 <4.0.0" + +dependencies: + bech32: ^0.2.2 + bip32_ed25519: ^0.5.0 + cbor: ^6.2.0 + convert: ^3.1.1 + pinenacl: ^0.5.1 + +dev_dependencies: + catalyst_analysis: + path: ../catalyst_analysis + test: ^1.24.9 diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/address_test.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/address_test.dart new file mode 100644 index 0000000000..26474d56cf --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/address_test.dart @@ -0,0 +1,74 @@ +import 'package:catalyst_cardano_serialization/src/address.dart'; +import 'package:catalyst_cardano_serialization/src/types.dart'; +import 'package:test/test.dart'; + +import 'utils/test_data.dart'; + +void main() { + group(ShelleyAddress, () { + test('round-trip conversion from and to bytes', () { + expect( + ShelleyAddress(mainnetAddr.bytes, hrp: mainnetAddr.hrp), + equals(mainnetAddr), + ); + + expect( + ShelleyAddress(testnetAddr.bytes, hrp: testnetAddr.hrp), + equals(testnetAddr), + ); + + expect( + ShelleyAddress(mainnetStakeAddr.bytes, hrp: mainnetStakeAddr.hrp), + equals(mainnetStakeAddr), + ); + + expect( + ShelleyAddress(testnetStakeAddr.bytes, hrp: testnetStakeAddr.hrp), + equals(testnetStakeAddr), + ); + }); + + test('round-trip conversion from and to bech32', () { + expect( + ShelleyAddress.fromBech32(mainnetAddr.toBech32()), + equals(mainnetAddr), + ); + + expect( + ShelleyAddress.fromBech32(testnetAddr.toBech32()), + equals(testnetAddr), + ); + + expect( + ShelleyAddress.fromBech32(mainnetStakeAddr.toBech32()), + equals(mainnetStakeAddr), + ); + + expect( + ShelleyAddress.fromBech32(testnetStakeAddr.toBech32()), + equals(testnetStakeAddr), + ); + }); + + test('hrp from address', () { + expect(mainnetAddr.hrp, equals('addr')); + expect(testnetAddr.hrp, equals('addr_test')); + expect(mainnetStakeAddr.hrp, equals('stake')); + expect(testnetStakeAddr.hrp, equals('stake_test')); + }); + + test('network ID from address', () { + expect(mainnetAddr.network, equals(NetworkId.mainnet)); + expect(testnetAddr.network, equals(NetworkId.testnet)); + expect(mainnetStakeAddr.network, equals(NetworkId.mainnet)); + expect(testnetStakeAddr.network, equals(NetworkId.testnet)); + }); + + test('toString returns bech32', () { + expect(mainnetAddr.toString(), equals(mainnetAddr.toBech32())); + expect(testnetAddr.toString(), equals(testnetAddr.toBech32())); + expect(mainnetStakeAddr.toString(), equals(mainnetStakeAddr.toBech32())); + expect(testnetStakeAddr.toString(), equals(testnetStakeAddr.toBech32())); + }); + }); +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/fees_test.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/fees_test.dart new file mode 100644 index 0000000000..3d5dbb360b --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/fees_test.dart @@ -0,0 +1,39 @@ +import 'package:catalyst_cardano_serialization/src/fees.dart'; +import 'package:catalyst_cardano_serialization/src/types.dart'; +import 'package:test/test.dart'; + +import 'utils/test_data.dart'; + +void main() { + group(LinearFee, () { + test('minFeeNoScript with current protocol params', () { + const linearFee = LinearFee( + constant: Coin(155381), + coefficient: Coin(44), + ); + + final tx = fullTestTransaction(); + expect(linearFee.minNoScriptFee(tx), equals(159693)); + }); + + test('minFeeNoScript with constant fee only', () { + const linearFee = LinearFee( + constant: Coin(155381), + coefficient: Coin(0), + ); + + final tx = fullTestTransaction(); + expect(linearFee.minNoScriptFee(tx), equals(linearFee.constant)); + }); + + test('minFeeNoScript with coefficient fee only', () { + const linearFee = LinearFee( + constant: Coin(0), + coefficient: Coin(44), + ); + + final tx = fullTestTransaction(); + expect(linearFee.minNoScriptFee(tx), equals(4312)); + }); + }); +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/hashes_test.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/hashes_test.dart new file mode 100644 index 0000000000..f55bedc45f --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/hashes_test.dart @@ -0,0 +1,73 @@ +import 'package:catalyst_cardano_serialization/src/hashes.dart'; +import 'package:catalyst_cardano_serialization/src/transaction.dart'; +import 'package:cbor/cbor.dart'; +import 'package:convert/convert.dart'; +import 'package:test/test.dart'; + +void main() { + group(TransactionHash, () { + const hexString = + '4d3f576f26db29139981a69443c2325daa812cc353a31b5a4db794a5bcbb06c2'; + final bytes = hex.decode(hexString); + + test('from and to hex', () { + final hash = TransactionHash.fromHex(hexString); + expect(hash.toHex(), equals(hexString)); + }); + + test('from and to bytes', () { + final hash = TransactionHash.fromBytes(bytes: bytes); + expect(hash.bytes, equals(bytes)); + }); + + test('toCbor returns bytes', () { + final hash = TransactionHash.fromBytes(bytes: bytes); + final encodedCbor = cbor.encode(hash.toCbor()); + final decodedCbor = cbor.decode(encodedCbor); + expect(decodedCbor, isA()); + expect((decodedCbor as CborBytes).bytes, equals(bytes)); + }); + }); + + group(AuxiliaryDataHash, () { + const hexString = + '4d3f576f26db29139981a69443c2325daa812cc353a31b5a4db794a5bcbb06c2'; + final bytes = hex.decode(hexString); + + test('from and to hex', () { + final hash = AuxiliaryDataHash.fromHex(hexString); + expect(hash.toHex(), equals(hexString)); + }); + + test('from and to bytes', () { + final hash = AuxiliaryDataHash.fromBytes(bytes: bytes); + expect(hash.bytes, equals(bytes)); + }); + + test('from auxiliary data', () { + final data = AuxiliaryData( + map: { + const CborSmallInt(1): CborString('Test'), + }, + ); + + final hash = AuxiliaryDataHash.fromAuxiliaryData(data); + expect( + hash, + equals( + AuxiliaryDataHash.fromHex( + '568d2b7d4052f6ce9f2c60b942b10da9d19e60c8bf24b17aa7bcfb3caffcc1ea', + ), + ), + ); + }); + + test('toCbor returns bytes', () { + final hash = AuxiliaryDataHash.fromBytes(bytes: bytes); + final encodedCbor = cbor.encode(hash.toCbor()); + final decodedCbor = cbor.decode(encodedCbor); + expect(decodedCbor, isA()); + expect((decodedCbor as CborBytes).bytes, equals(bytes)); + }); + }); +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_test.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_test.dart new file mode 100644 index 0000000000..b67c4a9183 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_test.dart @@ -0,0 +1,38 @@ +import 'package:catalyst_cardano_serialization/src/transaction.dart'; +import 'package:cbor/cbor.dart'; +import 'package:convert/convert.dart'; +import 'package:test/test.dart'; + +import 'utils/test_data.dart'; + +void main() { + group(Transaction, () { + test('transaction with all supported fields serialized to bytes', () { + final bytes = cbor.encode(fullTestTransaction().toCbor()); + final hexString = hex.encode(bytes); + + expect( + hexString, + equals( + '84a40081825820583a3a5150bc7990656020ffb4e5a1be1589ce6f1a430aacb8e7e0' + '89b894d3d101018182581d609493315cd92eb5d8c4304e67b7e16ae36d61d3450269' + '4657811a2c8e1a004c4b40021a009896800319a029a0f5a1016454657374', + ), + ); + }); + + test('transaction with required fields serialized to bytes', () { + final bytes = cbor.encode(minimalTestTransaction().toCbor()); + final hexString = hex.encode(bytes); + + expect( + hexString, + equals( + '84a30081825820583a3a5150bc7990656020ffb4e5a1be1589ce6f1a430aacb8e7e0' + '89b894d3d101018182581d609493315cd92eb5d8c4304e67b7e16ae36d61d3450269' + '4657811a2c8e1a004c4b40021a00989680a0f5d90103a0', + ), + ); + }); + }); +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/types_test.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/types_test.dart new file mode 100644 index 0000000000..7aeeaf13a4 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/types_test.dart @@ -0,0 +1,33 @@ +import 'package:catalyst_cardano_serialization/src/types.dart'; +import 'package:test/test.dart'; + +void main() { + group(Coin, () { + test('addition', () { + expect(const Coin(3) + const Coin(100), equals(const Coin(103))); + expect(const Coin(-100) + const Coin(100), equals(const Coin(0))); + }); + + test('subtraction', () { + expect(const Coin(5) - const Coin(2), equals(const Coin(3))); + expect(const Coin(10) - const Coin(27), equals(const Coin(-17))); + }); + + test('multiplication', () { + expect(const Coin(3) * const Coin(6), equals(const Coin(18))); + expect(const Coin(-5) * const Coin(7), equals(const Coin(-35))); + }); + + test('division', () { + expect(const Coin(3) ~/ const Coin(2), equals(const Coin(1))); + expect(const Coin(100) ~/ const Coin(50), equals(const Coin(2))); + }); + + test('comparison', () { + expect(const Coin(3) > const Coin(2), isTrue); + expect(const Coin(3) >= const Coin(3), isTrue); + expect(const Coin(100) < const Coin(100), isFalse); + expect(const Coin(100) <= const Coin(100), isTrue); + }); + }); +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/utils/test_data.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/utils/test_data.dart new file mode 100644 index 0000000000..ee58d63609 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/utils/test_data.dart @@ -0,0 +1,81 @@ +import 'package:catalyst_cardano_serialization/src/address.dart'; +import 'package:catalyst_cardano_serialization/src/hashes.dart'; +import 'package:catalyst_cardano_serialization/src/transaction.dart'; +import 'package:catalyst_cardano_serialization/src/types.dart'; +import 'package:cbor/cbor.dart'; + +/* cSpell:disable */ +final mainnetAddr = ShelleyAddress.fromBech32( + 'addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqws' + 'x5wktcd8cc3sq835lu7drv2xwl2wywfgse35a3x', +); +final testnetAddr = ShelleyAddress.fromBech32( + 'addr_test1vz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerspjrlsz', +); +final mainnetStakeAddr = ShelleyAddress.fromBech32( + 'stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw', +); +final testnetStakeAddr = ShelleyAddress.fromBech32( + 'stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn', +); + +final testTransactionHash = TransactionHash.fromHex( + '583a3a5150bc7990656020ffb4e5a1be' + '1589ce6f1a430aacb8e7e089b894d3d1', +); + +/// Returns a minimal transaction with optional fields skipped. +Transaction minimalTestTransaction() { + return Transaction( + body: TransactionBody( + inputs: { + TransactionInput( + transactionId: testTransactionHash, + index: 1, + ), + }, + outputs: [ + TransactionOutput( + address: testnetAddr, + amount: const Coin(5000000), + ), + ], + fee: const Coin(10000000), + ), + isValid: true, + ); +} + +/// Returns a full transaction with all possible optional fields. +Transaction fullTestTransaction() { + final auxiliaryData = AuxiliaryData( + map: { + const CborSmallInt(1): CborString('Test'), + }, + ); + + return Transaction( + body: TransactionBody( + inputs: { + TransactionInput( + transactionId: testTransactionHash, + index: 1, + ), + }, + outputs: [ + TransactionOutput( + address: testnetAddr, + amount: const Coin(5000000), + ), + ], + fee: const Coin(10000000), + ttl: const SlotBigNum(41001), + auxiliaryDataHash: AuxiliaryDataHash.fromAuxiliaryData(auxiliaryData), + networkId: NetworkId.testnet, + ), + isValid: true, + auxiliaryData: auxiliaryData, + ); +} + +/* cSpell:enable */ diff --git a/melos.yaml b/melos.yaml index 21730e065a..f930ba47bd 100644 --- a/melos.yaml +++ b/melos.yaml @@ -24,6 +24,11 @@ command: meta: ^1.10.0 result_type: ^0.2.0 plugin_platform_interface: ^2.1.7 + bech32: ^0.2.2 + bip32_ed25519: ^0.5.0 + cbor: ^6.2.0 + convert: ^3.1.1 + pinenacl: ^0.5.1 dev_dependencies: test: ^1.24.9 build_runner: ^2.3.3