From b868fafcbd67ebb69936d9438391abd7bce4b32f Mon Sep 17 00:00:00 2001 From: Stefano Cunego <93382903+kukkok3@users.noreply.github.com> Date: Mon, 20 May 2024 09:13:15 +0200 Subject: [PATCH 1/5] feat: bunp ci to 3.00.1 (#502) --- .config/dictionaries/project.dic | 15 +++++++-------- catalyst-gateway/Earthfile | 4 ++-- catalyst-gateway/event-db/Earthfile | 2 +- catalyst-gateway/tests/api_tests/Earthfile | 4 ++-- .../local-cluster/.vagrant/rgloader/loader.rb | 12 ++++++++++++ 5 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 utilities/local-cluster/.vagrant/rgloader/loader.rb diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 7d0d4d2fd8..06e46ff7ea 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -44,7 +44,9 @@ dotenvy dotglob drep dreps +dtscalac earthfile +Easterling Edgedriver emurgo encryptor @@ -88,6 +90,8 @@ libflutter lintfix localizable loguru +lovelace +lovelaces mdlint metadatum metadatums @@ -115,6 +119,7 @@ pbxproj Pdart permissionless pg_isready +pinenacl plpgsql podfile podhelper @@ -133,6 +138,7 @@ Replayability repr reqwest rfwtxt +rgloader ripgrep RPATH rustc @@ -176,6 +182,7 @@ Utxos vite vitss vkey +vkeys vkeywitness voteplan voteplans @@ -191,11 +198,3 @@ xctest xctestrun xcworkspace yoroi -multiplatform -Multiplatform -Easterling -lovelace -lovelaces -pinenacl -dtscalac -vkeys diff --git a/catalyst-gateway/Earthfile b/catalyst-gateway/Earthfile index 84597480cb..4433bdd7d2 100644 --- a/catalyst-gateway/Earthfile +++ b/catalyst-gateway/Earthfile @@ -1,7 +1,7 @@ VERSION 0.8 -IMPORT github.com/input-output-hk/catalyst-ci/earthly/rust:v3.00.0 AS rust-ci -IMPORT github.com/input-output-hk/catalyst-ci/earthly/mithril_snapshot:v3.00.0 AS mithril-snapshot-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/rust:v3.00.1 AS rust-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/mithril_snapshot:v3.00.1 AS mithril-snapshot-ci #cspell: words rustfmt toolsets USERARCH diff --git a/catalyst-gateway/event-db/Earthfile b/catalyst-gateway/event-db/Earthfile index c3b57a36d8..0295a24ca3 100644 --- a/catalyst-gateway/event-db/Earthfile +++ b/catalyst-gateway/event-db/Earthfile @@ -3,7 +3,7 @@ # the database and its associated software. VERSION 0.8 -IMPORT github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.00.0 AS postgresql-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.00.1 AS postgresql-ci # cspell: words diff --git a/catalyst-gateway/tests/api_tests/Earthfile b/catalyst-gateway/tests/api_tests/Earthfile index d25bfe8c1d..774241b5f0 100644 --- a/catalyst-gateway/tests/api_tests/Earthfile +++ b/catalyst-gateway/tests/api_tests/Earthfile @@ -1,8 +1,8 @@ VERSION 0.8 -IMPORT github.com/input-output-hk/catalyst-ci/earthly/python:v3.00.0 AS python-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/python:v3.00.1 AS python-ci -# builder : +# builder : builder: FROM python-ci+python-base diff --git a/utilities/local-cluster/.vagrant/rgloader/loader.rb b/utilities/local-cluster/.vagrant/rgloader/loader.rb new file mode 100644 index 0000000000..b6c81bf31b --- /dev/null +++ b/utilities/local-cluster/.vagrant/rgloader/loader.rb @@ -0,0 +1,12 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: BUSL-1.1 + +# This file loads the proper rgloader/loader.rb file that comes packaged +# with Vagrant so that encoded files can properly run with Vagrant. + +if ENV["VAGRANT_INSTALLER_EMBEDDED_DIR"] + require File.expand_path( + "rgloader/loader", ENV["VAGRANT_INSTALLER_EMBEDDED_DIR"]) +else + raise "Encoded files can't be read outside of the Vagrant installer." +end From a8ce54844bc5347a05b778e1096069477c2e4dee Mon Sep 17 00:00:00 2001 From: Steven Johnson Date: Mon, 20 May 2024 18:47:47 +0700 Subject: [PATCH 2/5] Delete utilities/local-cluster/.vagrant/rgloader/loader.rb Should not be in the codebase --- utilities/local-cluster/.vagrant/rgloader/loader.rb | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 utilities/local-cluster/.vagrant/rgloader/loader.rb diff --git a/utilities/local-cluster/.vagrant/rgloader/loader.rb b/utilities/local-cluster/.vagrant/rgloader/loader.rb deleted file mode 100644 index b6c81bf31b..0000000000 --- a/utilities/local-cluster/.vagrant/rgloader/loader.rb +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright (c) HashiCorp, Inc. -# SPDX-License-Identifier: BUSL-1.1 - -# This file loads the proper rgloader/loader.rb file that comes packaged -# with Vagrant so that encoded files can properly run with Vagrant. - -if ENV["VAGRANT_INSTALLER_EMBEDDED_DIR"] - require File.expand_path( - "rgloader/loader", ENV["VAGRANT_INSTALLER_EMBEDDED_DIR"]) -else - raise "Encoded files can't be read outside of the Vagrant installer." -end From f003e8d90d23d350ea07ee69a73d6be7c5af191b Mon Sep 17 00:00:00 2001 From: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Date: Mon, 20 May 2024 13:56:12 +0200 Subject: [PATCH 3/5] feat: catalyst cardano documentation (#507) * docs: improve readme * docs: add reference docs * docs: update readme formatting * docs: update readme structure --------- Co-authored-by: Oleksandr Prokhorenko --- .../catalyst_cardano_serialization/README.md | 219 +++++++++++++++++- 1 file changed, 218 insertions(+), 1 deletion(-) diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/README.md b/catalyst_voices_packages/catalyst_cardano_serialization/README.md index 191e021c7c..ebb003fad8 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/README.md +++ b/catalyst_voices_packages/catalyst_cardano_serialization/README.md @@ -1 +1,218 @@ -# catalyst_cardano_serialization +# Content + +* [Content](#content) + * [Features](#features) + * [Requirements](#requirements) + * [Install](#install) + * [Example](#example) + * [Limitations](#limitations) + * [Supported transaction body fields](#supported-transaction-body-fields) + * [Reference documentation](#reference-documentation) + * [Support](#support) + * [License](#license) + +## Features + +This package comes with serialization & deserialization of data structures related to Cardano +blockchain transactions and useful utility functions. + +The goal of the package is to generate an unsigned transaction cbor that +can be signed and submitted to the blockchain. +The package communicates neither with the wallet nor with the blockchain therefore signing +and submission are outside of scope of this package. + +## Requirements + +* Dart: 3.3.0+ +* Flutter: 3.19.5+ + +## Install + +```yaml +dependencies: + catalyst_cardano_serialization: ^0.1.0 +``` + +## Example + +Import `package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart`, +instantiate `TransactionBuilder`, provide transaction inputs, outputs, add change address +for any remaining unspent UTXOs and build the transaction body. + +The transaction body must be signed by witnesses in order to be submitted to the blockchain. +Otherwise the validity of the transaction could not be established and such transaction +would be rejected. +The caller must prove that they are eligible to spend the input UTXOs. + +```dart +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; +import 'package:cbor/cbor.dart'; +import 'package:convert/convert.dart'; + +/* cSpell:disable */ +void main() { + const txBuilderConfig = TransactionBuilderConfig( + feeAlgo: LinearFee( + constant: Coin(155381), + coefficient: Coin(44), + ), + maxTxSize: 8000, + coinsPerUtxoByte: Coin(4310), + ); + + final txMetadata = AuxiliaryData( + map: { + const CborSmallInt(1): CborString('Test'), + const CborSmallInt(2): CborBytes(hex.decode('aabbccddeeff')), + const CborSmallInt(3): const CborSmallInt(997), + const CborSmallInt(4): cbor.decode( + hex.decode( + '82a50081825820afcf8497561065afe1ca623823508753cc580eb575ac8f1d6cfa' + 'a18c3ceeac010001818258390080f9e2c88e6c817008f3a812ed889b4a4da8e0bd' + '103f86e7335422aa122a946b9ad3d2ddf029d3a828f0468aece76895f15c9efbd6' + '9b42771a00df1560021a0002e63003182f075820bdc2b27e6869aa9a5fa23a1f1f' + 'd3a87025d8703df4fd7b120d058c839dc0415c82a10141aa80', + ), + ), + }, + ); + + final utxo = TransactionUnspentOutput( + input: TransactionInput( + transactionId: TransactionHash.fromHex( + '4c1fbc5433ec764164945d736a09dc087d59ff30e64d26d462ff8570cd4be9a7', + ), + index: 0, + ), + output: TransactionOutput( + address: ShelleyAddress.fromBech32( + 'addr_test1qpu5vlrf4xkxv2qpwngf6cjhtw542ayty80v8dyr49rf5ewvxwdrt70' + 'qlcpeeagscasafhffqsxy36t90ldv06wqrk2qum8x5w', + ), + amount: const Coin(10162333), + ), + ); + + final txOutput = TransactionOutput( + address: ShelleyAddress.fromBech32( + 'addr_test1vzpwq95z3xyum8vqndgdd9mdnmafh3djcxnc6jemlgdmswcve6tkw', + ), + amount: const Coin(1000000), + ); + + final txBuilder = TransactionBuilder( + config: txBuilderConfig, + inputs: [utxo], + // fee can be left empty so that it's auto calculated or can be hardcoded + // fee: const Coin(1000000), + ttl: const SlotBigNum(410021), + auxiliaryData: txMetadata, + networkId: NetworkId.testnet, + ); + + final changeAddress = ShelleyAddress.fromBech32( + 'addr_test1qrqr2ved9h96x46yazq89yvcgk0r93gwk0shnv06yfrnfryqhpr26' + 'st0zgxmjnq6gqve99gtzxumclt9mwe5ynq03hjqgkjmhd', + ); + + final txBody = txBuilder + .withOutput(txOutput) + .withChangeAddressIfNeeded(changeAddress) + // fee can be set manually or left empty to be auto calculated + // .withFee(const Coin(10000000)) + .buildBody(); + + final txHash = TransactionHash.fromTransactionBody(txBody); + final witnessSet = _signTransaction(txHash); + + final tx = Transaction( + body: txBody, + isValid: true, + witnessSet: witnessSet, + auxiliaryData: txMetadata, + ); + + final txBytes = cbor.encode(tx.toCbor()); + final txBytesHex = hex.encode(txBytes); + print(txBytesHex); +} + +TransactionWitnessSet _signTransaction(TransactionHash txHash) { + // return a fake witness set, in real world the wallet + // would sign the transaction hash and provide this + return TransactionWitnessSet( + vkeyWitnesses: { + VkeyWitness( + vkey: Vkey.fromBytes( + hex.decode( + '3311ca404fcf22c91d607ace285d70e2' + '263a1b81745c39673080329bd1a3f56e', + ), + ), + signature: Ed25519Signature.fromBytes( + hex.decode( + 'f5eb006f048fdfa9b81b0fe3abee1ce1f1a75789d' + 'c21088b23ebf95c76b050ad157a497999e083e1957' + 'c2a3d730a07a5b2aef4a755783c9ce778c02c4a08970f', + ), + ), + ), + }, + ); +} + +/* cSpell:enable */ +``` + +## Limitations + +This package supports a minimal `TransactionBuilder` that does not yet work with +Smart Contracts, scripts or NFTs. +However AuxiliaryMetadata is already supported thus it's possible to fulfill some of the use cases. + +Only Shelley era bech32 base and stake addresses are supported. +Byron era addresses are not supported. + +## Supported transaction body fields + +| Field | Is supported? | +| ----- | ------------- | +| 0 = transaction inputs | ✔️ | +| 1 = transaction outputs | ✔️ | +| 2 = transaction fee | ✔️ | +| 3 = Time to live [TTL] | ✔️ | +| 4 = certificates | ❌️ | +| 5 = reward withdrawals | ❌️ | +| 6 = protocol parameter update | ❌️ | +| 7 = auxiliary_data_hash | ✔️ | +| 8 = validity interval start | ❌️ | +| 9 = mint | ❌️ | +| 11 = script_data_hash | ❌️ | +| 13 = collateral inputs | ❌️ | +| 14 = required signers | ❌️ | +| 15 = network_id | ✔️ | +| 16 = collateral return | ❌️ | +| 17 = total collateral | ❌️ | +| 18 = reference inputs | ❌️ | + +## Reference documentation + +* [Cardano transaction specification](https://github.com/input-output-hk/catalyst-CIPs/blob/x509-rbac-signing-with-cip30/CIP-XXXX/README.md#specification) +* [Cardano Multiplatform Lib](https://github.com/dcSpark/cardano-multiplatform-lib) with reference +implementation for fee calculation algorithm and change address management. + +## Support + +Post issues and feature requests on the GitHub [issue tracker](https://github.com/input-output-hk/catalyst-voices/issues). +Please read our [CONTRIBUTING](https://github.com/input-output-hk/catalyst-voices/blob/main/CONTRIBUTING.md) +for guidelines on how to contribute. + +## License + +Licensed under either of [Apache License, Version 2.0](https://github.com/input-output-hk/catalyst-voices/blob/main/LICENSE-APACHE) +or [MIT license](https://github.com/input-output-hk/catalyst-voices/blob/main/LICENSE-MIT) +at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in this crate by you, as defined in the Apache-2.0 license, shall +be dual licensed as above, without any additional terms or conditions. From e150394fb348e88b016e03ab69efe782f9daf94f Mon Sep 17 00:00:00 2001 From: Dominik Toton <166132265+dtscalac@users.noreply.github.com> Date: Tue, 21 May 2024 10:41:24 +0200 Subject: [PATCH 4/5] feat: catalyst cardano transaction builder (#501) * feat: add shelley address & cardano serialization lib * fix: address to string * style: disable cspell for cardano addresses * fix: address hashcode * refactor: make coin an extension type, rename magicId to id * style: use expression syntax * style: use expressions * style: typo * feat: add comparison operators * feat: add greater or equal, less or equal operators * feat: add utils for transaction hash * feat: add transaction serialization * feat: add algorithm for fee calculation * docs: improve method * style: add class modifier to prevent unintented overrides * feat: catalyst cardano serialization - transactions (#484) * fix: address hashcode * feat: add utils for transaction hash * feat: add transaction serialization * feat: add algorithm for fee calculation * docs: improve method * style: add class modifier to prevent unintented overrides --------- Co-authored-by: Dominik Toton * feat: add witnesses management * feat: add witnesses * feat: add witness builder * feat: add transaction builder * refactor: cleanup code * chore: cleanup todo * style: cleanup * chore: spelling * docs: improve transaction bulder docs * style: fix blank lines * style: format markdown --------- Co-authored-by: Dominik Toton --- .../example/main.dart | 119 ++++++ .../lib/catalyst_cardano_serialization.dart | 2 + .../lib/src/address.dart | 13 +- .../lib/src/builders/transaction_builder.dart | 360 ++++++++++++++++++ .../lib/src/builders/witness_builder.dart | 66 ++++ .../lib/src/exceptions.dart | 22 ++ .../lib/src/fees.dart | 3 +- .../lib/src/hashes.dart | 16 +- .../lib/src/transaction.dart | 97 ++++- .../lib/src/types.dart | 26 +- .../lib/src/utils/numbers.dart | 7 + .../lib/src/witness.dart | 37 +- .../test/builders/witness_builder_test.dart | 41 ++ .../test/fees_test.dart | 10 +- .../test/test_utils/test_data.dart | 176 +++++++-- .../test/transaction_test.dart | 121 +++++- .../local-cluster/.vagrant/rgloader/loader.rb | 12 + 17 files changed, 1054 insertions(+), 74 deletions(-) create mode 100644 catalyst_voices_packages/catalyst_cardano_serialization/example/main.dart create mode 100644 catalyst_voices_packages/catalyst_cardano_serialization/lib/src/builders/transaction_builder.dart create mode 100644 catalyst_voices_packages/catalyst_cardano_serialization/lib/src/builders/witness_builder.dart create mode 100644 catalyst_voices_packages/catalyst_cardano_serialization/lib/src/utils/numbers.dart create mode 100644 catalyst_voices_packages/catalyst_cardano_serialization/test/builders/witness_builder_test.dart create mode 100644 utilities/local-cluster/.vagrant/rgloader/loader.rb diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/example/main.dart b/catalyst_voices_packages/catalyst_cardano_serialization/example/main.dart new file mode 100644 index 0000000000..d67b773ace --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/example/main.dart @@ -0,0 +1,119 @@ +// ignore_for_file: avoid_print + +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; +import 'package:cbor/cbor.dart'; +import 'package:convert/convert.dart'; + +/* cSpell:disable */ +void main() { + const txBuilderConfig = TransactionBuilderConfig( + feeAlgo: LinearFee( + constant: Coin(155381), + coefficient: Coin(44), + ), + maxTxSize: 8000, + coinsPerUtxoByte: Coin(4310), + ); + + final txMetadata = AuxiliaryData( + map: { + const CborSmallInt(1): CborString('Test'), + const CborSmallInt(2): CborBytes(hex.decode('aabbccddeeff')), + const CborSmallInt(3): const CborSmallInt(997), + const CborSmallInt(4): cbor.decode( + hex.decode( + '82a50081825820afcf8497561065afe1ca623823508753cc580eb575ac8f1d6cfa' + 'a18c3ceeac010001818258390080f9e2c88e6c817008f3a812ed889b4a4da8e0bd' + '103f86e7335422aa122a946b9ad3d2ddf029d3a828f0468aece76895f15c9efbd6' + '9b42771a00df1560021a0002e63003182f075820bdc2b27e6869aa9a5fa23a1f1f' + 'd3a87025d8703df4fd7b120d058c839dc0415c82a10141aa80', + ), + ), + }, + ); + + final utxo = TransactionUnspentOutput( + input: TransactionInput( + transactionId: TransactionHash.fromHex( + '4c1fbc5433ec764164945d736a09dc087d59ff30e64d26d462ff8570cd4be9a7', + ), + index: 0, + ), + output: TransactionOutput( + address: ShelleyAddress.fromBech32( + 'addr_test1qpu5vlrf4xkxv2qpwngf6cjhtw542ayty80v8dyr49rf5ewvxwdrt70' + 'qlcpeeagscasafhffqsxy36t90ldv06wqrk2qum8x5w', + ), + amount: const Coin(10162333), + ), + ); + + final txOutput = TransactionOutput( + address: ShelleyAddress.fromBech32( + 'addr_test1vzpwq95z3xyum8vqndgdd9mdnmafh3djcxnc6jemlgdmswcve6tkw', + ), + amount: const Coin(1000000), + ); + + final txBuilder = TransactionBuilder( + config: txBuilderConfig, + inputs: [utxo], + // fee can be left empty so that it's auto calculated or can be hardcoded + // fee: const Coin(1000000), + ttl: const SlotBigNum(410021), + auxiliaryData: txMetadata, + networkId: NetworkId.testnet, + ); + + final changeAddress = ShelleyAddress.fromBech32( + 'addr_test1qrqr2ved9h96x46yazq89yvcgk0r93gwk0shnv06yfrnfryqhpr26' + 'st0zgxmjnq6gqve99gtzxumclt9mwe5ynq03hjqgkjmhd', + ); + + final txBody = txBuilder + .withOutput(txOutput) + .withChangeAddressIfNeeded(changeAddress) + // fee can be set manually or left empty to be auto calculated + // .withFee(const Coin(10000000)) + .buildBody(); + + final txHash = TransactionHash.fromTransactionBody(txBody); + final witnessSet = _signTransaction(txHash); + + final tx = Transaction( + body: txBody, + isValid: true, + witnessSet: witnessSet, + auxiliaryData: txMetadata, + ); + + final txBytes = cbor.encode(tx.toCbor()); + final txBytesHex = hex.encode(txBytes); + print(txBytesHex); +} + +TransactionWitnessSet _signTransaction(TransactionHash txHash) { + // return a fake witness set, in real world the wallet + // would sign the transaction hash and provide this + return TransactionWitnessSet( + vkeyWitnesses: { + VkeyWitness( + vkey: Vkey.fromBytes( + hex.decode( + '3311ca404fcf22c91d607ace285d70e2' + '263a1b81745c39673080329bd1a3f56e', + ), + ), + signature: Ed25519Signature.fromBytes( + hex.decode( + 'f5eb006f048fdfa9b81b0fe3abee1ce1f1a75789d' + 'c21088b23ebf95c76b050ad157a497999e083e1957' + 'c2a3d730a07a5b2aef4a755783c9ce778c02c4a08970f', + ), + ), + ), + }, + ); +} + +/* cSpell:enable */ 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 index bf48afc09d..d959115c70 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/catalyst_cardano_serialization.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/catalyst_cardano_serialization.dart @@ -1,4 +1,6 @@ export 'src/address.dart'; +export 'src/builders/transaction_builder.dart'; +export 'src/builders/witness_builder.dart'; export 'src/exceptions.dart'; export 'src/fees.dart'; export 'src/hashes.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 index a0aefa1477..ce335c9925 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/address.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/address.dart @@ -8,7 +8,7 @@ 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. +/// [ShelleyAddress] supports bech32 encoded addresses as defined in CIP-19. class ShelleyAddress { /// The prefix of a base address. static const String defaultAddrHrp = 'addr'; @@ -65,6 +65,14 @@ class ShelleyAddress { } } + /// Deserializes the type from cbor. + factory ShelleyAddress.fromCbor(CborValue value) { + return ShelleyAddress((value as CborBytes).bytes); + } + + /// Serializes the type as cbor. + CborValue toCbor() => CborBytes(bytes); + /// Returns the [NetworkId] related to this address. NetworkId get network => NetworkId.testnet.id == (bytes[0] & 0x0f) ? NetworkId.testnet @@ -87,9 +95,6 @@ class ShelleyAddress { } } - /// Serializes the type as cbor. - CborValue toCbor() => CborBytes(bytes); - @override int get hashCode => Object.hash(bytes, hrp); diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/builders/transaction_builder.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/builders/transaction_builder.dart new file mode 100644 index 0000000000..022d015e7c --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/builders/transaction_builder.dart @@ -0,0 +1,360 @@ +import 'package:catalyst_cardano_serialization/src/address.dart'; +import 'package:catalyst_cardano_serialization/src/builders/witness_builder.dart'; +import 'package:catalyst_cardano_serialization/src/exceptions.dart'; +import 'package:catalyst_cardano_serialization/src/fees.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:catalyst_cardano_serialization/src/utils/cbor.dart'; +import 'package:catalyst_cardano_serialization/src/utils/numbers.dart'; +import 'package:catalyst_cardano_serialization/src/witness.dart'; +import 'package:cbor/cbor.dart'; + +/// A builder which helps to create the [TransactionBody]. +/// +/// The class collects the [inputs], [outputs], calculates the [fee] +/// and adds a change address if some UTXOs are not fully spent. +/// +/// Algorithms inspired by [cardano-multiplatform-lib](https://github.com/dcSpark/cardano-multiplatform-lib/blob/255500b3c683618849fb38107896170ea09c95dc/chain/rust/src/builders/tx_builder.rs#L380) implementation. +class TransactionBuilder { + /// Contains the protocol parameters for Cardano blockchain. + final TransactionBuilderConfig config; + + /// The list of transaction outputs from previous transactions + /// that will be spent in the next transaction. + /// + /// Enough [inputs] must be provided to be greater or equal + /// the amount of [outputs] + [fee]. + final List inputs; + + /// The list of transaction outputs which describes which address + /// will receive what amount of [Coin]. + final List outputs; + + /// The amount of lovelaces that will be charged as the fee + /// for adding the transaction to the blockchain. + /// + /// If left null then it will be auto calculated, + /// set explicitly with [withFee] to specify a custom fee. + final Coin? fee; + + /// The absolute slot value before the tx becomes invalid. + final SlotBigNum? ttl; + + /// The transaction metadata as a list of key-value pairs (a map). + final AuxiliaryData? auxiliaryData; + + /// Specifies on which network the code will run. + final NetworkId? networkId; + + /// The builder that builds the witness set of the transaction. + /// + /// The caller must know in advance how many witnesses there will be to + /// reserve a correct amount of bytes in the transaction as it influences + /// the fee. + final TransactionWitnessSetBuilder witnessBuilder; + + /// The default constructor for [TransactionBuilder]. + const TransactionBuilder({ + required this.config, + required this.inputs, + this.outputs = const [], + this.fee, + this.ttl, + this.auxiliaryData, + this.networkId, + this.witnessBuilder = const TransactionWitnessSetBuilder( + vkeys: {}, + vkeysCount: 1, + ), + }); + + /// Returns a copy of this [TransactionBuilder] with extra [address] used + /// to spend any remaining change from the transaction, if it is needed. + /// + /// Since in a Cardano transaction the input amount must match the output + /// amount plus fee, the method must ensure that there are no unspent utxos. + /// + /// The algorithm first tries to create a [TransactionOutput] which will + /// transfer any remaining [Coin] back to the [address]. The [address] + /// should be the change address of the wallet initiating the transaction. + /// + /// If creating an extra [TransactionOutput] is not possible because + /// i.e. the remaining change is too small to cover for extra fee that such + /// extra output would generate then the transaction fee is increased to burn + /// any remaining change. + /// + /// Follows code style of Cardano Multiplatform Lib to make patching easy. + TransactionBuilder withChangeAddressIfNeeded(ShelleyAddress address) { + if (this.fee != null) { + // generating the change output involves changing the fee + return this; + } + + final fee = minFee(); + + final inputTotal = + inputs.map((e) => e.output.amount).reduce((a, b) => a + b); + final outputTotal = outputs.map((e) => e.amount).reduce((a, b) => a + b); + final outputTotalPlusFee = outputTotal + fee; + + if (outputTotalPlusFee == inputTotal) { + // ignore: avoid_returning_this + return this; + } else if (outputTotalPlusFee > inputTotal) { + throw InsufficientUtxoBalanceException( + actualAmount: inputTotal, + requiredAmount: outputTotalPlusFee, + ); + } else { + final changeEstimator = inputTotal - outputTotal; + + final minAda = TransactionOutputBuilder.minimumAdaForOutput( + TransactionOutput(address: address, amount: changeEstimator), + config.coinsPerUtxoByte, + ); + + switch (changeEstimator >= minAda) { + case false: + // burn remaining change as fee + return withFee(changeEstimator); + case true: + final feeForChange = TransactionOutputBuilder.feeForOutput( + this, + TransactionOutput( + address: address, + amount: changeEstimator, + ), + ); + + final newFee = fee + feeForChange; + + switch (changeEstimator >= minAda) { + case false: + // burn remaining change as fee + return withFee(changeEstimator); + case true: + return withFee(newFee).withOutput( + TransactionOutput( + address: address, + amount: changeEstimator - newFee, + ), + ); + } + } + } + } + + /// Returns a copy of this [TransactionBuilder] with the [fee]. + TransactionBuilder withFee(Coin fee) { + return _copyWith(fee: fee); + } + + /// Returns a copy of this [TransactionBuilder] with extra [output]. + /// + /// The [output] must reach a minimum [Coin] value as calculated + /// by [TransactionOutputBuilder.minimumAdaForOutput], + /// otherwise [TxValueBelowMinUtxoValueException] is thrown. + TransactionBuilder withOutput(TransactionOutput output) { + final minAdaPerUtxoEntry = TransactionOutputBuilder.minimumAdaForOutput( + output, + config.coinsPerUtxoByte, + ); + + if (output.amount < minAdaPerUtxoEntry) { + throw TxValueBelowMinUtxoValueException( + actualAmount: output.amount, + requiredAmount: minAdaPerUtxoEntry, + ); + } + + return _copyWith( + outputs: [...outputs, output], + ); + } + + /// Returns a copy of this [TransactionBuilder] with extra [vkeyWitness]. + TransactionBuilder withWitnessVkey(VkeyWitness vkeyWitness) { + final builder = witnessBuilder.addVkey(vkeyWitness); + return _copyWith(witnessBuilder: builder); + } + + /// Builds a [TransactionBody] and measures the final transaction size + /// that will be submitted to the blockchain. + /// + /// Knowing the final transaction size is very important as it influences + /// the [fee] the wallet must pay for the transaction. + /// + /// Since the transaction body is signed before all + (TransactionBody, int) buildAndSize() { + final built = _buildBody(); + + // we must build a tx with fake data (of correct size) + // to check the final transaction size + final fullTx = buildFakeTransaction(built); + final fullTxSize = cbor.encode(fullTx.toCbor()).length; + return (fullTx.body, fullTxSize); + } + + /// Returns the body of the new transaction. + /// + /// Throws [MaxTxSizeExceededException] if maximum transaction + /// size is reached. + TransactionBody buildBody() { + final (body, fullTxSize) = buildAndSize(); + if (fullTxSize > config.maxTxSize) { + throw MaxTxSizeExceededException( + maxTxSize: config.maxTxSize, + actualTxSize: fullTxSize, + ); + } + + return body; + } + + /// Constructs the rest of the Transaction using fake witness data of the + /// correct length for use in calculating the size of the final [Transaction]. + Transaction buildFakeTransaction(TransactionBody txBody) { + return Transaction( + body: txBody, + witnessSet: witnessBuilder.buildFake(), + isValid: true, + auxiliaryData: auxiliaryData, + ); + } + + /// Returns the size of the full transaction in bytes. + int fullSize() { + return buildAndSize().$2; + } + + /// Calculates the minimum transaction fee for this builder. + Coin minFee() { + final txBody = _copyWith(fee: const Coin(Numbers.intMaxValue)).buildBody(); + final fullTx = buildFakeTransaction(txBody); + return config.feeAlgo.minNoScriptFee(fullTx); + } + + TransactionBody _buildBody() { + final fee = this.fee; + if (fee == null) { + throw const TxFeeNotSpecifiedException(); + } + + return TransactionBody( + inputs: Set.of(inputs.map((e) => e.input)), + outputs: List.of(outputs), + fee: fee, + ttl: ttl, + auxiliaryDataHash: auxiliaryData != null + ? AuxiliaryDataHash.fromAuxiliaryData(auxiliaryData!) + : null, + networkId: networkId, + ); + } + + TransactionBuilder _copyWith({ + List? outputs, + Coin? fee, + TransactionWitnessSetBuilder? witnessBuilder, + }) { + return TransactionBuilder( + config: config, + inputs: inputs, + outputs: outputs ?? this.outputs, + fee: fee ?? this.fee, + ttl: ttl, + auxiliaryData: auxiliaryData, + networkId: networkId, + witnessBuilder: witnessBuilder ?? this.witnessBuilder, + ); + } +} + +/// A configuration for the [TransactionBuilder] which holds +/// protocol parameters and other constants. +class TransactionBuilderConfig { + /// The protocol parameter which describes the transaction fee algorithm. + final LinearFee feeAlgo; + + /// The protocol parameter which limits the maximum transaction size in bytes. + final int maxTxSize; + + /// The protocol parameter that establishes the minimum amount of [Coin] + /// required per UTXO entry. + /// + /// This prevents storing too many tiny UTXOs on the network. + final Coin coinsPerUtxoByte; + + /// The default constructor for [TransactionBuilderConfig]. + const TransactionBuilderConfig({ + required this.feeAlgo, + required this.maxTxSize, + required this.coinsPerUtxoByte, + }); +} + +/// Builder and utils around [TransactionOutput]. +class TransactionOutputBuilder { + /// Constant from figure 5 in Babbage spec meant to represent + /// the size of the input in a UTXO. + static const int constantOverhead = 160; + + /// Calculates the additional fee for adding the [output] to the [builder]. + static Coin feeForOutput( + TransactionBuilder builder, + TransactionOutput output, + ) { + final prev = builder.withFee(const Coin(0)); + final prevFee = prev.minFee(); + final next = prev.withOutput(output); + final nextFee = next.minFee(); + return nextFee - prevFee; + } + + /// Calculates the minimum amount of extra [Coin] for UTXO input. + /// + /// Adding extra output raises the transaction size which raises the fee, + /// raising the fee can increase the transaction size as more bytes might + /// need to be allocated to cover the higher fee. + /// + /// The algorithm considers all of above cases. + static Coin minimumAdaForOutput( + TransactionOutput output, + Coin coinsPerUtxoByte, + ) { + final outputSize = cbor.encode(output.toCbor()).length; + + // how many bytes the coin part of the value will take, + // can vary based on encoding used + final oldCoinSize = 1 + CborSize.ofInt(output.amount.value).bytes; + + // most recent estimate of the size in bytes to include + // the minimum ada value + var latestCoinSize = oldCoinSize; + + // we calculate min ada in a loop because every time we increase + // the min ada, it may increase the cbor size in bytes + while (true) { + final sizeDiff = latestCoinSize - oldCoinSize; + final tentativeMinAda = + Coin(outputSize + constantOverhead + sizeDiff) * coinsPerUtxoByte; + + final newCoinSize = 1 + CborSize.ofInt(tentativeMinAda.value).bytes; + final isDone = latestCoinSize == newCoinSize; + latestCoinSize = newCoinSize; + + if (isDone) { + break; + } + } + + // how many bytes the size changed from including the minimum ada value + final sizeChange = latestCoinSize - oldCoinSize; + + final adjustedMinAda = + Coin(outputSize + constantOverhead + sizeChange) * coinsPerUtxoByte; + + return adjustedMinAda; + } +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/builders/witness_builder.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/builders/witness_builder.dart new file mode 100644 index 0000000000..b042cfa32d --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/builders/witness_builder.dart @@ -0,0 +1,66 @@ +import 'package:catalyst_cardano_serialization/src/exceptions.dart'; +import 'package:catalyst_cardano_serialization/src/witness.dart'; + +/// A builder that builds [TransactionWitnessSet]. +/// +/// Holds transaction witnesses that sign the transaction and prove it is valid. +class TransactionWitnessSetBuilder { + /// The map of transaction witnesses. + final Map vkeys; + + /// The expected amount of [vkeys] that should be added via [addVkey] + /// before it is possible to call [build] method. + /// + /// The caller should know in advance how many witnesses there will + /// be in order to calculate the correct transaction fee before the + /// transaction is signed. + final int vkeysCount; + + /// The default constructor for [TransactionWitnessSetBuilder]. + const TransactionWitnessSetBuilder({ + required this.vkeys, + required this.vkeysCount, + }); + + /// Adds a [witness] to the set. + TransactionWitnessSetBuilder addVkey(VkeyWitness witness) { + final map = Map.of(vkeys); + map[witness.vkey] = witness; + return TransactionWitnessSetBuilder( + vkeys: map, + vkeysCount: vkeysCount, + ); + } + + /// Removes the witness with [vkey] from the set. + TransactionWitnessSetBuilder removeVkey(Vkey vkey) { + final map = Map.of(vkeys)..remove(vkey); + return TransactionWitnessSetBuilder( + vkeys: map, + vkeysCount: vkeysCount, + ); + } + + /// Builds the [TransactionWitnessSet]. Before calling this method make + /// sure that [vkeysCount] amount of witnesses have been added via [addVkey]. + TransactionWitnessSet build() { + if (vkeysCount != vkeys.values.length) { + throw const InvalidTransactionWitnessesException(); + } + + return TransactionWitnessSet( + vkeyWitnesses: vkeys.values.toSet(), + ); + } + + /// Builds the fake [TransactionWitnessSet] that is needed to reserve + /// enough bytes in the transaction for the future witnesses which + /// will sign the transaction. + TransactionWitnessSet buildFake() { + return TransactionWitnessSet( + vkeyWitnesses: { + for (int i = 0; i < vkeysCount; i++) VkeyWitness.seeded(i), + }, + ); + } +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/exceptions.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/exceptions.dart index ba6eb0c25a..883d9bad8e 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/exceptions.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/exceptions.dart @@ -51,6 +51,28 @@ final class TxFeeNotSpecifiedException implements Exception { String toString() => 'TxFeeNotSpecifiedException'; } +/// Exception thrown when the transaction output amount +/// is less than required by the network. +final class TxValueBelowMinUtxoValueException implements Exception { + /// The amount of [Coin] in the transaction output. + final Coin actualAmount; + + /// The amount of [Coin] that is the minimum. + final Coin requiredAmount; + + /// The default constructor for [TxValueBelowMinUtxoValueException]. + const TxValueBelowMinUtxoValueException({ + required this.actualAmount, + required this.requiredAmount, + }); + + @override + String toString() => 'TxValueBelowMinUtxoValueException(' + 'actualAmount:$actualAmount' + ', requiredAmount:$requiredAmount' + ')'; +} + /// Exception thrown when parsing a hash that has incorrect length. final class HashFormatException implements Exception { /// The default constructor for [HashFormatException]. diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/fees.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/fees.dart index a47471798c..555c91cb71 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/fees.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/fees.dart @@ -13,7 +13,8 @@ final class LinearFee { /// The amount of [Coin] per transaction byte that is charged per transaction. final Coin coefficient; - /// The default constructor for the [LinearFee]. + /// The default constructor for [LinearFee]. + /// /// The parameters are Cardano protocol parameters. const LinearFee({ required this.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 index e499cbc87b..3253eab90a 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/hashes.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/hashes.dart @@ -21,6 +21,13 @@ abstract base class BaseHash { } } + /// Deserializes the type from cbor. + BaseHash.fromCbor(CborValue value) + : this.fromBytes(bytes: (value as CborBytes).bytes); + + /// Serializes the type as cbor. + CborValue toCbor() => CborBytes(bytes); + /// Constructs the [BaseHash] from a hex string representation /// of [bytes]. BaseHash.fromHex(String string) : this.fromBytes(bytes: hex.decode(string)); @@ -28,9 +35,6 @@ abstract base class BaseHash { /// 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); @@ -80,6 +84,9 @@ final class TransactionHash extends BaseHash { ), ); + /// Deserializes the type from cbor. + TransactionHash.fromCbor(super.value) : super.fromCbor(); + @override int get length => _length; } @@ -105,6 +112,9 @@ final class AuxiliaryDataHash extends BaseHash { ), ); + /// Deserializes the type from cbor. + AuxiliaryDataHash.fromCbor(super.value) : super.fromCbor(); + @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 index 5b26b34d08..c845c81581 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/transaction.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/transaction.dart @@ -20,7 +20,7 @@ final class Transaction { /// The optional transaction metadata. final AuxiliaryData? auxiliaryData; - /// The default constructor for the [Transaction]. + /// The default constructor for [Transaction]. const Transaction({ required this.body, required this.isValid, @@ -28,6 +28,23 @@ final class Transaction { this.auxiliaryData, }); + /// Deserializes the type from cbor. + factory Transaction.fromCbor(CborValue value) { + final list = value as CborList; + final body = list[0]; + final witnessSet = list[1]; + final isValid = list[2]; + final auxiliaryData = list.length >= 4 ? list[3] : null; + + return Transaction( + body: TransactionBody.fromCbor(body), + isValid: (isValid as CborBool).value, + witnessSet: TransactionWitnessSet.fromCbor(witnessSet), + auxiliaryData: + auxiliaryData != null ? AuxiliaryData.fromCbor(auxiliaryData) : null, + ); + } + /// Serializes the type as cbor. CborValue toCbor() { return CborList([ @@ -72,6 +89,28 @@ final class TransactionBody { this.networkId, }); + /// Deserializes the type from cbor. + factory TransactionBody.fromCbor(CborValue value) { + final map = value as CborMap; + final inputs = map[const CborSmallInt(0)]! as CborList; + final outputs = map[const CborSmallInt(1)]! as CborList; + final fee = map[const CborSmallInt(2)]!; + final ttl = map[const CborSmallInt(3)]; + final auxiliaryDataHash = map[const CborSmallInt(7)]; + final networkId = map[const CborSmallInt(15)] as CborSmallInt?; + + return TransactionBody( + inputs: inputs.map(TransactionInput.fromCbor).toSet(), + outputs: outputs.map(TransactionOutput.fromCbor).toList(), + fee: Coin.fromCbor(fee), + ttl: ttl != null ? SlotBigNum.fromCbor(ttl) : null, + auxiliaryDataHash: auxiliaryDataHash != null + ? AuxiliaryDataHash.fromCbor(auxiliaryDataHash) + : null, + networkId: networkId != null ? NetworkId.fromId(networkId.value) : null, + ); + } + /// Serializes the type as cbor. CborValue toCbor() { return CborMap({ @@ -83,6 +122,10 @@ final class TransactionBody { ]), const CborSmallInt(2): fee.toCbor(), if (ttl != null) const CborSmallInt(3): ttl!.toCbor(), + if (auxiliaryDataHash != null) + const CborSmallInt(7): auxiliaryDataHash!.toCbor(), + if (networkId != null) + const CborSmallInt(15): CborSmallInt(networkId!.id), }); } } @@ -102,6 +145,18 @@ final class TransactionInput { required this.index, }); + /// Deserializes the type from cbor. + factory TransactionInput.fromCbor(CborValue value) { + final list = value as CborList; + final transactionId = list[0]; + final index = list[1]; + + return TransactionInput( + transactionId: TransactionHash.fromCbor(transactionId), + index: (index as CborSmallInt).value, + ); + } + /// Serializes the type as cbor. CborValue toCbor() { return CborList([ @@ -111,8 +166,8 @@ final class TransactionInput { } } -/// The transaction output which assigns the owner of given address -/// with leftover change from previous transaction. +/// The transaction output which describes which [address] +/// will receive what [amount] of [Coin]. final class TransactionOutput { /// The address associated with the transaction. final ShelleyAddress address; @@ -120,12 +175,24 @@ final class TransactionOutput { /// The leftover change from the previous transaction that can be spent. final Coin amount; - /// The default constructor for the [TransactionOutput]. + /// The default constructor for [TransactionOutput]. const TransactionOutput({ required this.address, required this.amount, }); + /// Deserializes the type from cbor. + factory TransactionOutput.fromCbor(CborValue value) { + final list = value as CborList; + final address = list[0]; + final amount = list[1]; + + return TransactionOutput( + address: ShelleyAddress.fromCbor(address), + amount: Coin.fromCbor(amount), + ); + } + /// Serializes the type as cbor. CborValue toCbor() { return CborList([ @@ -151,6 +218,18 @@ final class TransactionUnspentOutput { required this.output, }); + /// Deserializes the type from cbor. + factory TransactionUnspentOutput.fromCbor(CborValue value) { + final list = value as CborList; + final input = list[0]; + final output = list[1]; + + return TransactionUnspentOutput( + input: TransactionInput.fromCbor(input), + output: TransactionOutput.fromCbor(output), + ); + } + /// Serializes the type as cbor. CborValue toCbor() { return CborList([ @@ -163,11 +242,17 @@ final class TransactionUnspentOutput { /// The transaction metadata as a list of key-value pairs (a map). final class AuxiliaryData { /// The transaction metadata map. - final Map map; + final Map map; - /// The default constructor for the [AuxiliaryData]. + /// The default constructor for [AuxiliaryData]. const AuxiliaryData({this.map = const {}}); + /// Deserializes the type from cbor. + factory AuxiliaryData.fromCbor(CborValue value) { + final map = value as CborMap; + return AuxiliaryData(map: Map.fromEntries(map.entries)); + } + /// Serializes the type as cbor. CborValue toCbor() { return CborMap( diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/types.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/types.dart index addf9f731c..87b6a722b5 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/types.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/types.dart @@ -12,10 +12,28 @@ enum NetworkId { final int id; const NetworkId({required this.id}); + + factory NetworkId.fromId(int id) { + for (final value in values) { + if (value.id == id) { + return value; + } + } + + throw ArgumentError('Unsupported NetworkId: $id'); + } } /// Specifies an amount of ADA in terms of lovelace. extension type const Coin(int value) { + /// Deserializes the type from cbor. + factory Coin.fromCbor(CborValue value) { + return Coin((value as CborSmallInt).value); + } + + /// Serializes the type as cbor. + CborValue toCbor() => CborSmallInt(value); + /// Adds [other] value to this value and returns a new [Coin]. Coin operator +(Coin other) => Coin(value + other.value); @@ -40,13 +58,15 @@ extension type const Coin(int 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) { + /// Deserializes the type from cbor. + factory SlotBigNum.fromCbor(CborValue value) { + return SlotBigNum((value as CborSmallInt).value); + } + /// Serializes the type as cbor. CborValue toCbor() => CborSmallInt(value); } diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/utils/numbers.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/utils/numbers.dart new file mode 100644 index 0000000000..c8ffbbb1fc --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/utils/numbers.dart @@ -0,0 +1,7 @@ +/// Utils around numbers and arithmetics. +final class Numbers { + /// int value is limited by web constraints. + /// + /// See: https://api.dart.dev/stable/3.4.0/dart-core/int-class.html + static const int intMaxValue = 9007199254740991; +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/witness.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/witness.dart index 7ad376805f..8f8e64f532 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/witness.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/witness.dart @@ -8,6 +8,14 @@ class TransactionWitnessSet { /// The default constructor for [TransactionWitnessSet]. const TransactionWitnessSet({required this.vkeyWitnesses}); + /// Deserializes the type from cbor. + factory TransactionWitnessSet.fromCbor(CborValue value) { + final map = value as CborMap; + return TransactionWitnessSet( + vkeyWitnesses: map.values.map(VkeyWitness.fromCbor).toSet(), + ); + } + /// Serializes the type as cbor. CborValue toCbor() { return CborMap({ @@ -40,11 +48,26 @@ class VkeyWitness { ); } + /// Deserializes the type from cbor. + factory VkeyWitness.fromCbor(CborValue value) { + final list = value as CborList; + final innerList = list[0] as CborList; + final vkey = innerList[0]; + final signature = innerList[1]; + + return VkeyWitness( + vkey: Vkey.fromCbor(vkey), + signature: Ed25519Signature.fromCbor(signature), + ); + } + /// Serializes the type as cbor. CborValue toCbor() { return CborList([ - vkey.toCbor(), - signature.toCbor(), + CborList([ + vkey.toCbor(), + signature.toCbor(), + ]), ]); } } @@ -65,6 +88,11 @@ extension type Vkey._(List bytes) { /// used to reserve size to calculate the final transaction bytes size. factory Vkey.seeded(int byte) => Vkey.fromBytes(List.filled(length, byte)); + /// Deserializes the type from cbor. + factory Vkey.fromCbor(CborValue value) { + return Vkey.fromBytes((value as CborBytes).bytes); + } + /// Serializes the type as cbor. CborValue toCbor() => CborBytes(bytes); } @@ -88,6 +116,11 @@ extension type Ed25519Signature._(List bytes) { factory Ed25519Signature.seeded(int byte) => Ed25519Signature.fromBytes(List.filled(length, byte)); + /// Deserializes the type from cbor. + factory Ed25519Signature.fromCbor(CborValue value) { + return Ed25519Signature.fromBytes((value as CborBytes).bytes); + } + /// Serializes the type as cbor. CborValue toCbor() => CborBytes(bytes); } diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/builders/witness_builder_test.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/builders/witness_builder_test.dart new file mode 100644 index 0000000000..a5f64ce901 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/builders/witness_builder_test.dart @@ -0,0 +1,41 @@ +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; +import 'package:test/test.dart'; + +void main() { + group(TransactionWitnessSetBuilder, () { + final witness = VkeyWitness.seeded(1); + + test('add vkey', () { + const builder = TransactionWitnessSetBuilder(vkeys: {}, vkeysCount: 1); + final witnessSet = builder.addVkey(witness).build(); + expect(witnessSet.vkeyWitnesses.length, equals(1)); + expect(witnessSet.vkeyWitnesses.first, equals(witness)); + }); + + test('remove vkey', () { + final builder = TransactionWitnessSetBuilder( + vkeys: {witness.vkey: witness}, + vkeysCount: 1, + ); + final updatedBuilder = builder.removeVkey(witness.vkey); + expect(updatedBuilder.vkeys, isEmpty); + }); + + test('build not matching expected vkeys count throws exception', () { + const builder = TransactionWitnessSetBuilder(vkeys: {}, vkeysCount: 1); + expect( + builder.build, + throwsA(const InvalidTransactionWitnessesException()), + ); + }); + + test('build fake will provide seeded witnesses', () { + const builder = TransactionWitnessSetBuilder(vkeys: {}, vkeysCount: 2); + final witnessSet = builder.buildFake(); + expect( + witnessSet.vkeyWitnesses.length, + equals(builder.vkeysCount), + ); + }); + }); +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/fees_test.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/fees_test.dart index 82482ec4b5..aa5e40c769 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/test/fees_test.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/fees_test.dart @@ -12,8 +12,8 @@ void main() { coefficient: Coin(44), ); - final tx = fullTestTransaction(); - expect(linearFee.minNoScriptFee(tx), equals(159693)); + final tx = fullSignedTestTransaction(); + expect(linearFee.minNoScriptFee(tx), equals(176369)); }); test('minFeeNoScript with constant fee only', () { @@ -22,7 +22,7 @@ void main() { coefficient: Coin(0), ); - final tx = fullTestTransaction(); + final tx = fullSignedTestTransaction(); expect(linearFee.minNoScriptFee(tx), equals(linearFee.constant)); }); @@ -32,8 +32,8 @@ void main() { coefficient: Coin(44), ); - final tx = fullTestTransaction(); - expect(linearFee.minNoScriptFee(tx), equals(4312)); + final tx = fullSignedTestTransaction(); + expect(linearFee.minNoScriptFee(tx), equals(20988)); }); }); } diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/test_utils/test_data.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/test_utils/test_data.dart index 44e148c686..88775f4482 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/test/test_utils/test_data.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/test_utils/test_data.dart @@ -4,6 +4,7 @@ import 'package:catalyst_cardano_serialization/src/transaction.dart'; import 'package:catalyst_cardano_serialization/src/types.dart'; import 'package:catalyst_cardano_serialization/src/witness.dart'; import 'package:cbor/cbor.dart'; +import 'package:convert/convert.dart'; /* cSpell:disable */ final mainnetAddr = ShelleyAddress.fromBech32( @@ -11,7 +12,11 @@ final mainnetAddr = ShelleyAddress.fromBech32( 'x5wktcd8cc3sq835lu7drv2xwl2wywfgse35a3x', ); final testnetAddr = ShelleyAddress.fromBech32( - 'addr_test1vz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerspjrlsz', + 'addr_test1vzpwq95z3xyum8vqndgdd9mdnmafh3djcxnc6jemlgdmswcve6tkw', +); +final testnetChangeAddr = ShelleyAddress.fromBech32( + 'addr_test1qrqr2ved9h96x46yazq89yvcgk0r93gwk0shnv06yfrnfryqhpr26' + 'st0zgxmjnq6gqve99gtzxumclt9mwe5ynq03hjqgkjmhd', ); final mainnetStakeAddr = ShelleyAddress.fromBech32( 'stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw', @@ -21,67 +26,180 @@ final testnetStakeAddr = ShelleyAddress.fromBech32( ); final testTransactionHash = TransactionHash.fromHex( - '583a3a5150bc7990656020ffb4e5a1be' - '1589ce6f1a430aacb8e7e089b894d3d1', + '4c1fbc5433ec764164945d736a09dc087d59ff30e64d26d462ff8570cd4be9a7', ); +TransactionUnspentOutput testUtxo() { + return TransactionUnspentOutput( + input: TransactionInput( + transactionId: testTransactionHash, + index: 0, + ), + output: TransactionOutput( + address: ShelleyAddress.fromBech32( + 'addr_test1qpu5vlrf4xkxv2qpwngf6cjhtw542ayty80v8dyr49rf5' + 'ewvxwdrt70qlcpeeagscasafhffqsxy36t90ldv06wqrk2qum8x5w', + ), + amount: const Coin(100000000), + ), + ); +} + +Transaction minimalUnsignedTestTransaction() { + return Transaction( + body: TransactionBody( + inputs: {testUtxo().input}, + outputs: [ + TransactionOutput( + address: testnetAddr, + amount: const Coin(1000000), + ), + TransactionOutput( + address: testnetChangeAddr, + amount: const Coin(9998833003), + ), + ], + fee: const Coin(166997), + ), + isValid: true, + witnessSet: const TransactionWitnessSet( + vkeyWitnesses: {}, + ), + ); +} + /// Returns a minimal transaction with optional fields skipped. -Transaction minimalTestTransaction() { +Transaction minimalSignedTestTransaction() { return Transaction( body: TransactionBody( - inputs: { - TransactionInput( - transactionId: testTransactionHash, - index: 1, + inputs: {testUtxo().input}, + outputs: [ + TransactionOutput( + address: testnetAddr, + amount: const Coin(1000000), + ), + TransactionOutput( + address: testnetChangeAddr, + amount: const Coin(9998833003), + ), + ], + fee: const Coin(166997), + ), + isValid: true, + witnessSet: TransactionWitnessSet( + vkeyWitnesses: { + VkeyWitness( + vkey: Vkey.fromBytes( + hex.decode( + '3311ca404fcf22c91d607ace285d70e2' + '263a1b81745c39673080329bd1a3f56e', + ), + ), + signature: Ed25519Signature.fromBytes( + hex.decode( + '85b3a67a0529c95a740fd643e2998f03f251268ca' + '603a0778b6631966b9a43fd2e02fa907c610ecc98' + '5b375fa9852c14789dacd2ab7897b445efe4f4b0f60a06', + ), + ), ), }, + ), + ); +} + +Transaction fullUnsignedTestTransaction() { + final auxiliaryData = testAuxiliaryData(); + + return Transaction( + body: TransactionBody( + inputs: {testUtxo().input}, outputs: [ TransactionOutput( address: testnetAddr, - amount: const Coin(5000000), + amount: const Coin(1000000), + ), + TransactionOutput( + address: testnetChangeAddr, + amount: const Coin(9998832827), ), ], - fee: const Coin(10000000), + fee: const Coin(167173), + ttl: const SlotBigNum(41193), + auxiliaryDataHash: AuxiliaryDataHash.fromAuxiliaryData(auxiliaryData), + networkId: NetworkId.testnet, ), isValid: true, - witnessSet: const TransactionWitnessSet(vkeyWitnesses: {}), + witnessSet: const TransactionWitnessSet( + vkeyWitnesses: {}, + ), + auxiliaryData: auxiliaryData, ); } /// Returns a full transaction with all possible optional fields. -Transaction fullTestTransaction() { - final auxiliaryData = AuxiliaryData( - map: { - const CborSmallInt(1): CborString('Test'), - }, - ); +Transaction fullSignedTestTransaction() { + final auxiliaryData = testAuxiliaryData(); return Transaction( body: TransactionBody( - inputs: { - TransactionInput( - transactionId: testTransactionHash, - index: 1, - ), - }, + inputs: {testUtxo().input}, outputs: [ TransactionOutput( address: testnetAddr, - amount: const Coin(5000000), + amount: const Coin(1000000), + ), + TransactionOutput( + address: testnetChangeAddr, + amount: const Coin(9998832827), ), ], - fee: const Coin(10000000), - ttl: const SlotBigNum(41001), + fee: const Coin(167173), + ttl: const SlotBigNum(41193), auxiliaryDataHash: AuxiliaryDataHash.fromAuxiliaryData(auxiliaryData), networkId: NetworkId.testnet, ), isValid: true, - // TODO(dtscalac): provide witness - witnessSet: const TransactionWitnessSet( - vkeyWitnesses: {}, + witnessSet: TransactionWitnessSet( + vkeyWitnesses: { + VkeyWitness( + vkey: Vkey.fromBytes( + hex.decode( + '3311ca404fcf22c91d607ace285d70e2' + '263a1b81745c39673080329bd1a3f56e', + ), + ), + signature: Ed25519Signature.fromBytes( + hex.decode( + 'f5eb006f048fdfa9b81b0fe3abee1ce1f1a75789d' + 'c21088b23ebf95c76b050ad157a497999e083e1957' + 'c2a3d730a07a5b2aef4a755783c9ce778c02c4a08970f', + ), + ), + ), + }, ), auxiliaryData: auxiliaryData, ); } +AuxiliaryData testAuxiliaryData() { + return AuxiliaryData( + map: { + const CborSmallInt(1): CborString('Test'), + const CborSmallInt(2): CborBytes(hex.decode('aabbccddeeff')), + const CborSmallInt(3): const CborSmallInt(997), + const CborSmallInt(4): cbor.decode( + hex.decode( + '82a50081825820afcf8497561065afe1ca623823508753cc580eb575ac8f1d6cfa' + 'a18c3ceeac010001818258390080f9e2c88e6c817008f3a812ed889b4a4da8e0bd' + '103f86e7335422aa122a946b9ad3d2ddf029d3a828f0468aece76895f15c9efbd6' + '9b42771a00df1560021a0002e63003182f075820bdc2b27e6869aa9a5fa23a1f1f' + 'd3a87025d8703df4fd7b120d058c839dc0415c82a10141aa80', + ), + ), + }, + ); +} + /* cSpell:enable */ diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_test.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_test.dart index 52d8f88084..17217c3f3b 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_test.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_test.dart @@ -7,32 +7,111 @@ import 'test_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('full signed transaction serialized to cbor', () { + _testTransactionSerialization( + fullSignedTestTransaction(), + '84a600818258204c1fbc5433ec764164945d736a09dc087d59ff30e64d26d462ff' + '8570cd4be9a700018282581d6082e016828989cd9d809b50d6976d9efa9bc5b2c1' + 'a78d4b3bfa1bb83b1a000f424082583900c035332d2dcba35744e880729198459e' + '32c50eb3e179b1fa2247348c80b846ad416f120db94c1a401992950b11b9bc7d65' + 'dbb3424c0f8de41b0000000253fa14bb021a00028d050319a0e907582057b9d497' + '6bc8017e5b95c6996bac1749765e188c990b5c705a65c78f8349227d0f00a10081' + '8258203311ca404fcf22c91d607ace285d70e2263a1b81745c39673080329bd1a3' + 'f56e5840f5eb006f048fdfa9b81b0fe3abee1ce1f1a75789dc21088b23ebf95c76' + 'b050ad157a497999e083e1957c2a3d730a07a5b2aef4a755783c9ce778c02c4a08' + '970ff5a40164546573740246aabbccddeeff031903e50482a50081825820afcf84' + '97561065afe1ca623823508753cc580eb575ac8f1d6cfaa18c3ceeac0100018182' + '58390080f9e2c88e6c817008f3a812ed889b4a4da8e0bd103f86e7335422aa122a' + '946b9ad3d2ddf029d3a828f0468aece76895f15c9efbd69b42771a00df1560021a' + '0002e63003182f075820bdc2b27e6869aa9a5fa23a1f1fd3a87025d8703df4fd7b' + '120d058c839dc0415c82a10141aa80', ); }); - test('transaction with required fields serialized to bytes', () { - final bytes = cbor.encode(minimalTestTransaction().toCbor()); - final hexString = hex.encode(bytes); + test('full signed transaction serialized to and from cbor', () { + _testTransactionSerializationRoundTrip(fullSignedTestTransaction()); + }); + + test('full unsigned transaction serialized to cbor', () { + _testTransactionSerialization( + fullUnsignedTestTransaction(), + '84a600818258204c1fbc5433ec764164945d736a09dc087d59ff30e64d26d462ff' + '8570cd4be9a700018282581d6082e016828989cd9d809b50d6976d9efa9bc5b2c1' + 'a78d4b3bfa1bb83b1a000f424082583900c035332d2dcba35744e880729198459e' + '32c50eb3e179b1fa2247348c80b846ad416f120db94c1a401992950b11b9bc7d65' + 'dbb3424c0f8de41b0000000253fa14bb021a00028d050319a0e907582057b9d497' + '6bc8017e5b95c6996bac1749765e188c990b5c705a65c78f8349227d0f00a0f5a4' + '0164546573740246aabbccddeeff031903e50482a50081825820afcf8497561065' + 'afe1ca623823508753cc580eb575ac8f1d6cfaa18c3ceeac010001818258390080' + 'f9e2c88e6c817008f3a812ed889b4a4da8e0bd103f86e7335422aa122a946b9ad3' + 'd2ddf029d3a828f0468aece76895f15c9efbd69b42771a00df1560021a0002e630' + '03182f075820bdc2b27e6869aa9a5fa23a1f1fd3a87025d8703df4fd7b120d058c' + '839dc0415c82a10141aa80', + ); + }); + + test('full unsigned transaction serialized to and from cbor', () { + _testTransactionSerializationRoundTrip(fullUnsignedTestTransaction()); + }); - expect( - hexString, - equals( - '84a30081825820583a3a5150bc7990656020ffb4e5a1be1589ce6f1a430aacb8e7e0' - '89b894d3d101018182581d609493315cd92eb5d8c4304e67b7e16ae36d61d3450269' - '4657811a2c8e1a004c4b40021a00989680a0f5d90103a0', - ), + test('minimal signed transaction serialized to cbor', () { + _testTransactionSerialization( + minimalSignedTestTransaction(), + '84a300818258204c1fbc5433ec764164945d736a09dc087d59ff30e64d26d462ff85' + '70cd4be9a700018282581d6082e016828989cd9d809b50d6976d9efa9bc5b2c1a78d' + '4b3bfa1bb83b1a000f424082583900c035332d2dcba35744e880729198459e32c50e' + 'b3e179b1fa2247348c80b846ad416f120db94c1a401992950b11b9bc7d65dbb3424c' + '0f8de41b0000000253fa156b021a00028c55a100818258203311ca404fcf22c91d60' + '7ace285d70e2263a1b81745c39673080329bd1a3f56e584085b3a67a0529c95a740f' + 'd643e2998f03f251268ca603a0778b6631966b9a43fd2e02fa907c610ecc985b375f' + 'a9852c14789dacd2ab7897b445efe4f4b0f60a06f5d90103a0', + ); + }); + + test('minimal signed transaction serialized to and from cbor', () { + _testTransactionSerializationRoundTrip( + minimalSignedTestTransaction(), + ); + }); + + test('minimal unsigned transaction serialized to and from cbor', () { + _testTransactionSerializationRoundTrip(fullUnsignedTestTransaction()); + }); + + test('minimal unsigned transaction serialized to cbor', () { + _testTransactionSerialization( + minimalUnsignedTestTransaction(), + '84a300818258204c1fbc5433ec764164945d736a09dc087d59ff30e64d26d4' + '62ff8570cd4be9a700018282581d6082e016828989cd9d809b50d6976d9efa' + '9bc5b2c1a78d4b3bfa1bb83b1a000f424082583900c035332d2dcba35744e8' + '80729198459e32c50eb3e179b1fa2247348c80b846ad416f120db94c1a4019' + '92950b11b9bc7d65dbb3424c0f8de41b0000000253fa156b021a00028c55a0' + 'f5d90103a0', + ); + }); + + test('minimal unsigned transaction serialized to and from cbor', () { + _testTransactionSerializationRoundTrip( + minimalUnsignedTestTransaction(), ); }); }); } + +void _testTransactionSerializationRoundTrip(Transaction transaction) { + final hex1 = hex.encode(cbor.encode(transaction.toCbor())); + final tx1 = Transaction.fromCbor(cbor.decode(hex.decode(hex1))); + final hex2 = hex.encode(cbor.encode(tx1.toCbor())); + + expect(hex1, equals(hex2)); +} + +void _testTransactionSerialization( + Transaction transaction, + String expectedHex, +) { + final bytes = cbor.encode(transaction.toCbor()); + final hexString = hex.encode(bytes); + + expect(hexString, equals(expectedHex)); +} diff --git a/utilities/local-cluster/.vagrant/rgloader/loader.rb b/utilities/local-cluster/.vagrant/rgloader/loader.rb new file mode 100644 index 0000000000..b6c81bf31b --- /dev/null +++ b/utilities/local-cluster/.vagrant/rgloader/loader.rb @@ -0,0 +1,12 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: BUSL-1.1 + +# This file loads the proper rgloader/loader.rb file that comes packaged +# with Vagrant so that encoded files can properly run with Vagrant. + +if ENV["VAGRANT_INSTALLER_EMBEDDED_DIR"] + require File.expand_path( + "rgloader/loader", ENV["VAGRANT_INSTALLER_EMBEDDED_DIR"]) +else + raise "Encoded files can't be read outside of the Vagrant installer." +end From 976b02efe2c2ed60902b672f280be08686588e4d Mon Sep 17 00:00:00 2001 From: SotaTek-DuyLe <75770191+SotaTek-DuyLe@users.noreply.github.com> Date: Wed, 22 May 2024 18:37:01 +0700 Subject: [PATCH 5/5] feat: create test reporting for backend python tests (#511) * feat: update earthfile and allure yml * fix: earthfile path * fix: spelling --------- Co-authored-by: Dee --- .config/dictionaries/project.dic | 2 ++ .github/workflows/generate-allure-report.yml | 12 ++++++++++++ catalyst-gateway/tests/api_tests/.gitignore | 3 ++- catalyst-gateway/tests/api_tests/Earthfile | 5 ++++- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 06e46ff7ea..16219e6214 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -11,6 +11,7 @@ asmjs asyncio asyncpg auditability +backendpython bech bkioshn bluefireteam @@ -78,6 +79,7 @@ Joaquín jorm jormungandr Jörmungandr +junitxml junitreport Keyhash keyserver diff --git a/.github/workflows/generate-allure-report.yml b/.github/workflows/generate-allure-report.yml index 9bd73285f9..fec4a0ca2d 100644 --- a/.github/workflows/generate-allure-report.yml +++ b/.github/workflows/generate-allure-report.yml @@ -71,6 +71,18 @@ jobs: target_flags: runner_address: ${{ secrets.EARTHLY_SATELLITE_ADDRESS }} artifact: "false" + + - name: Get backend python test report + uses: input-output-hk/catalyst-ci/actions/run@master + if: always() + continue-on-error: true + with: + earthfile: ./catalyst-gateway/tests/api_tests/ + flags: --allow-privileged + targets: test + target_flags: + runner_address: ${{ secrets.EARTHLY_SATELLITE_ADDRESS }} + artifact: "false" - name: Collect and upload test reports uses: actions/upload-artifact@v4 diff --git a/catalyst-gateway/tests/api_tests/.gitignore b/catalyst-gateway/tests/api_tests/.gitignore index ed8ebf583f..40f65ddf37 100644 --- a/catalyst-gateway/tests/api_tests/.gitignore +++ b/catalyst-gateway/tests/api_tests/.gitignore @@ -1 +1,2 @@ -__pycache__ \ No newline at end of file +__pycache__ +junit-report.xml \ No newline at end of file diff --git a/catalyst-gateway/tests/api_tests/Earthfile b/catalyst-gateway/tests/api_tests/Earthfile index 774241b5f0..c59267f905 100644 --- a/catalyst-gateway/tests/api_tests/Earthfile +++ b/catalyst-gateway/tests/api_tests/Earthfile @@ -22,5 +22,8 @@ test: --load cat-gateway:latest=(../../+package-cat-gateway-with-preprod-snapshot) \ --service cat-gateway \ --allow-privileged - RUN poetry run pytest -s + RUN poetry run pytest -s --junitxml=junit-report.xml + END + WAIT + SAVE ARTIFACT junit-report.xml AS LOCAL backendpython.junit-report.xml END