diff --git a/integration-test/bridge-consensus/application.conf b/integration-test/bridge-consensus/application.conf new file mode 100644 index 0000000..7d70af9 --- /dev/null +++ b/integration-test/bridge-consensus/application.conf @@ -0,0 +1,40 @@ +bridge { + replica { + # the unique number that identifies this replica + replicaId = 0 + requests { + # the host where we are listening for requests + host = "[::]" + # the port where we are listening for requests + port = 4000 + } + # security configuration + security { + # path to the public key file + publicKeyFile = "/app/wallet/consensusPublicKey.pem" + # path to the private key file + privateKeyFile = "/app/wallet/consensusPrivateKey.pem" + } + consensus { + replicaCount = 1 + # map mapping each replica to its corresponding backend + replicas = { + 0 = { + publicKeyFile = "/app/wallet/publicKey.pem" + } + } + } + clients { + clientCount = 1 + # map mapping each client to its corresponding client + clients = { + 0 = { + publicKeyFile = "/app/wallet/clientPublicKey.pem" + host = "bridgepublicapi" + port = 6000 + secure = "false" + } + } + } + } +} \ No newline at end of file diff --git a/integration-test/bridge-consensus/bridge-consensus.Dockerfile b/integration-test/bridge-consensus/bridge-consensus.Dockerfile new file mode 100644 index 0000000..7825693 --- /dev/null +++ b/integration-test/bridge-consensus/bridge-consensus.Dockerfile @@ -0,0 +1,4 @@ +FROM ghcr.io/topl/topl-btc-bridge-consensus:latest + +ENTRYPOINT ["sh", "/bridge_custom_launch.sh"] +CMD [] diff --git a/integration-test/bridge-consensus/bridge_custom_launch.sh b/integration-test/bridge-consensus/bridge_custom_launch.sh new file mode 100644 index 0000000..e7e451e --- /dev/null +++ b/integration-test/bridge-consensus/bridge_custom_launch.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +export SERIES_ID=$(cat /app/wallet/seriesId.txt) +export GROUP_ID=$(cat /app/wallet/groupId.txt) +/opt/docker/bin/topl-btc-bridge-consensus --topl-host bifrost --btc-url http://bitcoin --zmq-host bitcoin --topl-wallet-seed-file /app/wallet/topl-wallet.json --topl-wallet-db /app/wallet/topl-wallet.db --btc-peg-in-seed-file /app/btc-wallet/peg-in-wallet.json --btc-wallet-seed-file /app/btc-wallet/btc-wallet.json --abtc-group-id $GROUP_ID --abtc-series-id $SERIES_ID --config-file /application.conf diff --git a/integration-test/bridge-public-api/application.conf b/integration-test/bridge-public-api/application.conf new file mode 100644 index 0000000..5ba2093 --- /dev/null +++ b/integration-test/bridge-public-api/application.conf @@ -0,0 +1,31 @@ +bridge { + client { + # the unique number that identifies this client + clientId = 0 + responses { + # the host where we are listening for responses + host = "[::]" + # the port where we are listening for responses + port = 6000 + } + # security configuration + security { + # path to the public key file + publicKeyFile = "/app/wallet/clientPublicKey.pem" + # path to the private key file + privateKeyFile = "/app/wallet/clientPrivateKey.pem" + } + consensus { + replicaCount = 1 + # map mapping each replica to its corresponding backend + replicas = { + 0 = { + publicKeyFile = "/app/wallet/consensusPublicKey.pem" + host = "bridgeconsensus" + port = 4000 + secure = "false" + } + } + } + } +} \ No newline at end of file diff --git a/integration-test/docker-compose.yaml b/integration-test/docker-compose.yaml index 7672cd5..4156fe3 100644 --- a/integration-test/docker-compose.yaml +++ b/integration-test/docker-compose.yaml @@ -9,25 +9,10 @@ services: - "18444:18444" - "18443:18443" - "28332:28332" - bridge: - image: toplprotocol/topl-btc-bridge:latest - command: - - "--topl-host" - - "bifrost" - - "--btc-url" - - "http://bitcoin" - - "--zmq-host" - - "bitcoin" - - "--topl-wallet-seed-file" - - "/app/wallet/topl-wallet.json" - - "--topl-wallet-db" - - "/app/wallet/topl-wallet.db" - - "--btc-peg-in-seed-file" - - "/app/btc-wallet/peg-in-wallet.json" - - "--btc-wallet-seed-file" - - "/app/btc-wallet/btc-wallet.json" - ports: - - "4000:4000" + bridgeconsensus: + build: + context: bridge-consensus + dockerfile: bridge-consensus.Dockerfile depends_on: bifrost: condition: service_started @@ -39,12 +24,42 @@ services: - wallet:/app/wallet - ./peg-in-wallet.json:/app/btc-wallet/peg-in-wallet.json - ./btc-wallet.json:/app/btc-wallet/btc-wallet.json + - ./bridge-consensus/bridge_custom_launch.sh:/bridge_custom_launch.sh + - ./bridge-consensus/application.conf:/application.conf + bridgepublicapi: + image: ghcr.io/topl/topl-btc-bridge-public-api:latest + command: + - "--config-file" + - "/application.conf" + ports: + - "5000:5000" + depends_on: + bifrost: + condition: service_started + bitcoin: + condition: service_started + bridge_init: + condition: service_completed_successfully + volumes: + - wallet:/app/wallet + - ./bridge-public-api/application.conf:/application.conf + envoy: + build: + context: envoy + dockerfile: envoy.Dockerfile + ports: + - "9094:9094" + links: + - bifrost + volumes: + - ./envoy/config.yaml:/etc/envoy/config.yaml bridge_init: build: - context: . + context: init dockerfile: bridge-init.Dockerfile volumes: - wallet:/app/wallet + - ./init/bridge_init.sh:/bridge_init.sh depends_on: - bifrost diff --git a/integration-test/envoy/config.yaml b/integration-test/envoy/config.yaml new file mode 100644 index 0000000..86b492e --- /dev/null +++ b/integration-test/envoy/config.yaml @@ -0,0 +1,55 @@ +static_resources: + listeners: + - name: listener_0 + address: + socket_address: { address: 0.0.0.0, port_value: 9094 } + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + codec_type: auto + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: { prefix: "/" } + route: + cluster: greeter_service + max_stream_duration: + grpc_timeout_header_max: 0s + cors: + allow_origin_string_match: + - prefix: "*" + allow_methods: GET, PUT, DELETE, POST, OPTIONS + allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout + max_age: "1728000" + expose_headers: custom-header-1,grpc-status,grpc-message + http_filters: + - name: envoy.filters.http.grpc_web + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb + - name: envoy.filters.http.cors + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + clusters: + - name: greeter_service + connect_timeout: 0.25s + type: logical_dns + http2_protocol_options: {} + lb_policy: round_robin + load_assignment: + cluster_name: cluster_0 + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: bifrost + port_value: 9084 diff --git a/integration-test/envoy/envoy.Dockerfile b/integration-test/envoy/envoy.Dockerfile new file mode 100644 index 0000000..c3a266d --- /dev/null +++ b/integration-test/envoy/envoy.Dockerfile @@ -0,0 +1,6 @@ +FROM envoyproxy/envoy-alpine:v1.21-latest + +RUN apk update +RUN apk add ngrep + +CMD ["envoy", "-l","trace", "-c","/etc/envoy/config.yaml"] diff --git a/integration-test/bridge-init.Dockerfile b/integration-test/init/bridge-init.Dockerfile similarity index 90% rename from integration-test/bridge-init.Dockerfile rename to integration-test/init/bridge-init.Dockerfile index 792f22f..d085ef0 100644 --- a/integration-test/bridge-init.Dockerfile +++ b/integration-test/init/bridge-init.Dockerfile @@ -1,7 +1,7 @@ FROM eclipse-temurin:11-jdk RUN apt update \ - && apt install --no-install-recommends -y curl gzip wget \ + && apt install --no-install-recommends -y curl gzip wget openssl \ && apt-get clean RUN mkdir /bitcoin && wget -qO- https://bitcoin.org/bin/bitcoin-core-27.0/bitcoin-27.0-x86_64-linux-gnu.tar.gz | tar xvz -C /bitcoin && install -m 0755 -o root -g root -t /usr/local/bin /bitcoin/bitcoin-27.0/bin/* @@ -21,7 +21,7 @@ USER 1001:0 RUN cs setup --yes RUN cs fetch -r https://s01.oss.sonatype.org/content/repositories/releases co.topl:brambl-cli_2.13:2.0.0-beta5 -COPY bridge_init.sh /bridge_init.sh +COPY extract_group_series_id.sh /extract_group_series_id.sh ENTRYPOINT ["sh", "/bridge_init.sh"] CMD [] diff --git a/integration-test/bridge_init.sh b/integration-test/init/bridge_init.sh similarity index 78% rename from integration-test/bridge_init.sh rename to integration-test/init/bridge_init.sh index 79dc1c4..6d1f483 100644 --- a/integration-test/bridge_init.sh +++ b/integration-test/init/bridge_init.sh @@ -13,6 +13,11 @@ export TOPL_WALLET_MNEMONIC=/app/wallet/topl-mnemonic.txt rm -f $TOPL_WALLET_DB $TOPL_WALLET_JSON $TOPL_WALLET_MNEMONIC +openssl ecparam -name secp256k1 -genkey -noout -out /app/wallet/consensusPrivateKey.pem +openssl ec -in /app/wallet/consensusPrivateKey.pem -pubout -out /app/wallet/consensusPublicKey.pem +openssl ecparam -name secp256k1 -genkey -noout -out /app/wallet/clientPrivateKey.pem +openssl ec -in /app/wallet/clientPrivateKey.pem -pubout -out /app/wallet/clientPublicKey.pem + bitcoin-cli -regtest -named -rpcconnect=bitcoin -rpcuser=bitcoin -rpcpassword=password createwallet wallet_name=testwallet export BTC_ADDRESS=`bitcoin-cli -rpcconnect=bitcoin -rpcuser=$BTC_USER -rpcpassword=$BTC_PASSWORD -rpcwallet=testwallet -regtest getnewaddress` echo BTC Address: $BTC_ADDRESS @@ -23,7 +28,9 @@ export ADDRESS=$(brambl-cli wallet current-address --walletdb $TOPL_WALLET_DB) cd /app -brambl-cli simple-transaction create --from-fellowship nofellowship --from-template genesis --from-interaction 1 --change-fellowship nofellowship --change-template genesis --change-interaction 1 -t $ADDRESS -w $TOPL_WALLET_PASSWORD -o genesisTx.pbuf -n private -a 100000 -h bifrost --port 9084 --keyfile $TOPL_WALLET_JSON --walletdb $TOPL_WALLET_DB --fee 10 --transfer-token lvl +echo "Genesis UTxOs" +brambl-cli genus-query utxo-by-address --from-fellowship nofellowship --from-template genesis --host bifrost --port 9084 --secure false --walletdb $TOPL_WALLET_DB +brambl-cli simple-transaction create --from-fellowship nofellowship --from-template genesis --from-interaction 1 --change-fellowship nofellowship --change-template genesis --change-interaction 1 -t $ADDRESS -w $TOPL_WALLET_PASSWORD -o genesisTx.pbuf -n private -a 10000 -h bifrost --port 9084 --keyfile $TOPL_WALLET_JSON --walletdb $TOPL_WALLET_DB --fee 10 --transfer-token lvl brambl-cli tx prove -i genesisTx.pbuf --walletdb $TOPL_WALLET_DB --keyfile $TOPL_WALLET_JSON -w $TOPL_WALLET_PASSWORD -o genesisTxProved.pbuf export GROUP_UTXO=$(brambl-cli tx broadcast -i genesisTxProved.pbuf -h bifrost --port 9084 --secure false) echo "GROUP_UTXO: $GROUP_UTXO" @@ -45,3 +52,5 @@ export ASSET_UTXO=$(brambl-cli tx broadcast -i seriesMintingTxProved.pbuf -h bif echo "ASSET_UTXO: $ASSET_UTXO" until brambl-cli genus-query utxo-by-address --host bifrost --port 9084 --secure false --walletdb $TOPL_WALLET_DB; do sleep 5; done brambl-cli wallet balance --from-fellowship self --from-template default --walletdb $TOPL_WALLET_DB --host bifrost --port 9084 --secure false +echo $(brambl-cli genus-query utxo-by-address -h bifrost --port 9084 --secure false --walletdb $TOPL_WALLET_DB | /bin/bash /extract_group_series_id.sh "Group Constructor") > /app/wallet/groupId.txt +echo $(brambl-cli genus-query utxo-by-address -h bifrost --port 9084 --secure false --walletdb $TOPL_WALLET_DB | /bin/bash /extract_group_series_id.sh "Series Constructor") > /app/wallet/seriesId.txt diff --git a/integration-test/init/extract_group_series_id.sh b/integration-test/init/extract_group_series_id.sh new file mode 100644 index 0000000..6e52772 --- /dev/null +++ b/integration-test/init/extract_group_series_id.sh @@ -0,0 +1,18 @@ + +#!/bin/bash + +TOKEN_TYPE=$1 +# Extract the Id of series or group tokens from the input + +while IFS= read -r line; do + # Check if the line contains "Type" and if the next line should be read + if [[ $line == *"Type"* && $line == *$TOKEN_TYPE* ]]; then + read_next_line=true + elif [ "$read_next_line" = true ]; then + # If the next line should be read and contains "Id", extract and print the Id + if [[ $line == "Id"* ]]; then + echo "$line" | awk '{print $NF}' + fi + read_next_line=false + fi +done < /dev/stdin # Read from stdin diff --git a/lib/features/bridge/providers/rpc_channel.dart b/lib/features/bridge/providers/rpc_channel.dart new file mode 100644 index 0000000..ef72a73 --- /dev/null +++ b/lib/features/bridge/providers/rpc_channel.dart @@ -0,0 +1,23 @@ +import 'package:apparatus_wallet/utils/rpc/channel_factory_web.dart'; +import 'package:grpc/grpc_connection_interface.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'rpc_channel.g.dart'; + +@riverpod +class RpcChannel extends _$RpcChannel { + @override + RpcChannelState build() { + // TODO: User Provided + final channel = makeChannel("localhost", 9094, false); + return RpcChannelState(nodeRpcChannel: channel, genusRpcChannel: channel); + } +} + +class RpcChannelState { + final ClientChannelBase nodeRpcChannel; + final ClientChannelBase genusRpcChannel; + + RpcChannelState( + {required this.nodeRpcChannel, required this.genusRpcChannel}); +} diff --git a/lib/features/bridge/providers/service_kit.dart b/lib/features/bridge/providers/service_kit.dart new file mode 100644 index 0000000..7d0efec --- /dev/null +++ b/lib/features/bridge/providers/service_kit.dart @@ -0,0 +1,40 @@ +import 'package:brambldart/brambldart.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:servicekit/servicekit.dart'; + +part 'service_kit.freezed.dart'; + +part 'service_kit.g.dart'; + +@riverpod +class ServiceKit extends _$ServiceKit { + @override + Future build() async { + final storage = await StorageApi.init(); + return ServiceKitState.base(storage); + } +} + +@freezed +class ServiceKitState with _$ServiceKitState { + factory ServiceKitState( + {required StorageApi storageApi, + required ContractStorageApi contractStorageApi, + required FellowshipStorageApi fellowshipStorageApi, + required WalletApi walletApi, + required WalletStateApi walletStateApi}) = _ServiceKitState; + + factory ServiceKitState.base(StorageApi storage) { + final contractStorage = ContractStorageApi(storage.sembast); + final fellowshipStorage = FellowshipStorageApi(storage.sembast); + final walletState = WalletStateApi(storage.sembast, storage.secureStorage); + final wallet = walletState.api; + return ServiceKitState( + storageApi: storage, + contractStorageApi: contractStorage, + fellowshipStorageApi: fellowshipStorage, + walletStateApi: walletState, + walletApi: wallet); + } +} diff --git a/lib/features/bridge/providers/utxos_state.dart b/lib/features/bridge/providers/utxos_state.dart new file mode 100644 index 0000000..966f823 --- /dev/null +++ b/lib/features/bridge/providers/utxos_state.dart @@ -0,0 +1,59 @@ +import 'package:apparatus_wallet/features/bridge/providers/rpc_channel.dart'; +import 'package:apparatus_wallet/features/bridge/providers/service_kit.dart'; +import 'package:apparatus_wallet/features/bridge/providers/wallet_key_vault.dart'; +import 'package:brambldart/brambldart.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:topl_common/proto/brambl/models/address.pb.dart'; +import 'package:topl_common/proto/genus/genus_models.pb.dart'; +import 'package:topl_common/proto/genus/genus_rpc.pbgrpc.dart'; +import 'package:topl_common/proto/node/services/bifrost_rpc.pbgrpc.dart'; + +part 'utxos_state.g.dart'; + +@riverpod +class UtxosState extends _$UtxosState { + @override + Stream build() async* { + await ref.watch(walletKeyVaultProvider.future); + final serviceKit = await ref.watch(serviceKitProvider.future); + final rpcChannels = ref.watch(rpcChannelProvider); + final nodeRpcClient = NodeRpcClient(rpcChannels.nodeRpcChannel); + final genusRpcClient = + TransactionServiceClient(rpcChannels.genusRpcChannel); + + final encodedGenesisAddress = + serviceKit.walletStateApi.getAddress("nofellowship", "genesis", 1)!; + final genesisAddress = + AddressCodecs.decode(encodedGenesisAddress).getOrThrow(); + + Future fetchUtxos() async { + // Get the current address from the wallet + final encodedAddress = serviceKit.walletStateApi.getCurrentAddress(); + final address = AddressCodecs.decode(encodedAddress).getOrThrow(); + final utxosList = [ + ...(await genusRpcClient.getTxosByLockAddress(QueryByLockAddressRequest( + address: address, state: TxoState.UNSPENT))) + .txos, + // In addition to the local wallet's funds, also fetch the public genesis funds + ...(await genusRpcClient.getTxosByLockAddress(QueryByLockAddressRequest( + address: genesisAddress, state: TxoState.UNSPENT))) + .txos, + ]; + final utxos = + Map.fromEntries(utxosList.map((u) => MapEntry(u.outputAddress, u))); + return utxos; + } + + yield await fetchUtxos(); + + // For each block adoption or unadoption + await for (final _ in nodeRpcClient + .synchronizationTraversal(SynchronizationTraversalReq())) { + // Wait 1 second for Genus to catch up + await Future.delayed(const Duration(seconds: 1)); + yield await fetchUtxos(); + } + } +} + +typedef UtxosMap = Map; diff --git a/lib/features/bridge/providers/wallet_key_vault.dart b/lib/features/bridge/providers/wallet_key_vault.dart new file mode 100644 index 0000000..6324a73 --- /dev/null +++ b/lib/features/bridge/providers/wallet_key_vault.dart @@ -0,0 +1,49 @@ +import 'package:apparatus_wallet/features/bridge/providers/service_kit.dart'; +import 'package:brambldart/brambldart.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'wallet_key_vault.g.dart'; + +@riverpod +class WalletKeyVault extends _$WalletKeyVault { + @override + Future build() async { + final serviceKit = await ref.watch(serviceKitProvider.future); + + // TODO: User provided + final password = List.filled(32, 0); + + late VaultStore wallet; + // First, attempt to load an existing wallet + final loadResult = await serviceKit.walletApi.loadWallet(); + await loadResult.fold( + // Loading a wallet may fail for a variety of reasons + (e) async { + // If it failed because it doesn't exist, then we can take a happy-path and create a new one. + if (e.type == WalletApiFailureType.failedToLoadWallet) { + final saveResult = + (await serviceKit.walletApi.createNewWallet(password)) + .getOrThrow(); + wallet = saveResult.mainKeyVaultStore; + (await serviceKit.walletApi.saveWallet(wallet)).getOrThrow(); + final mainKey = serviceKit.walletApi + .extractMainKey(wallet, password) + .getOrThrow(); + await serviceKit.walletStateApi.initWalletState( + NetworkConstants.privateNetworkId, + NetworkConstants.mainLedgerId, + mainKey.vk, + ); + // Otherwise, if it failed for some other reason, then a wallet likely exists; it's just unparseable + // TODO: What to do in this situation? + } else { + throw e; + } + }, + // If the wallet loaded successfully, hooray! + (w) async => wallet = w, + ); + + return wallet; + } +} diff --git a/lib/features/bridge/widgets/utxos_view.dart b/lib/features/bridge/widgets/utxos_view.dart new file mode 100644 index 0000000..d2ddbd4 --- /dev/null +++ b/lib/features/bridge/widgets/utxos_view.dart @@ -0,0 +1,311 @@ +import 'package:apparatus_wallet/features/bridge/providers/utxos_state.dart'; +import 'package:brambldart/brambldart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:fpdart/fpdart.dart' hide State; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:topl_common/proto/brambl/models/address.pb.dart'; +import 'package:topl_common/proto/brambl/models/box/value.pb.dart'; +import 'package:topl_common/proto/brambl/models/transaction/io_transaction.pb.dart'; +import 'package:topl_common/proto/brambl/models/transaction/spent_transaction_output.pb.dart'; +import 'package:topl_common/proto/brambl/models/transaction/unspent_transaction_output.pb.dart'; +import 'package:topl_common/proto/genus/genus_models.pb.dart'; +import 'package:topl_common/proto/quivr/models/shared.pb.dart'; + +class UtxosView extends HookConsumerWidget { + const UtxosView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final utxos = ref.watch(utxosStateProvider); + + return Scaffold( + body: body(utxos), + ); + } + + Widget body(AsyncValue utxos) { + return switch (utxos) { + AsyncData(:final value) => + TransactView(utxos: value, submitTransaction: (tx) {}), + AsyncError(:final error) => + Center(child: Text("Utxos failed to load. Reason: $error")), + _ => const Center(child: CircularProgressIndicator()) + }; + } +} + +class TransactView extends StatefulWidget { + final UtxosMap utxos; + final Function(IoTransaction) submitTransaction; + + const TransactView( + {super.key, required this.utxos, required this.submitTransaction}); + @override + State createState() => TransactViewState(); +} + +class TransactViewState extends State { + // TODO: Move this state into Riverpod + Set _selectedInputs = {}; + List<(String valueStr, String addressStr)> _newOutputEntries = []; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Card( + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: ExpansionPanelList.radio(children: [ + ExpansionPanelRadio( + value: "Inputs", + headerBuilder: (context, isExpanded) => + const ListTile(title: Text("Inputs")), + body: _inputsTile(), + ), + ExpansionPanelRadio( + value: "Outputs", + headerBuilder: (context, isExpanded) => + const ListTile(title: Text("Outputs")), + body: _outputsTile()), + ]), + ), + ), + IconButton( + onPressed: _transact, + icon: const Icon(Icons.send), + ) + ], + ), + ), + ); + } + + Int128 _inputSum() => _selectedInputs + .toList() + .map((v) => widget.utxos[v]!.transactionOutput.value.quantity) + .nonNulls + .fold(BigInt.zero.toInt128(), (a, b) => a + b); + + Future _createTransaction() async { + var tx = IoTransaction(); + + for (final ref in _selectedInputs) { + final output = widget.utxos[ref]!; + final input = SpentTransactionOutput() + ..value = output.transactionOutput.value + ..address = ref; + tx.inputs.add(input); + } + + for (final e in _newOutputEntries) { + // TODO: Error handling + final lockAddress = AddressCodecs.decode(e.$2).getOrThrow(); + // TODO: e.$1 value - is it a LVL? + final value = Value(); + final output = UnspentTransactionOutput() + ..address = lockAddress + ..value = value; + tx.outputs.add(output); + } + // TODO: Prove/sign + + return tx; + } + + _transact() async { + final tx = await _createTransaction(); + await widget.submitTransaction(tx); + setState(() { + _selectedInputs = {}; + _newOutputEntries = []; + }); + } + + Widget _outputsTile() { + return Column( + children: [ + DataTable( + columns: _outputTableHeader, + rows: [ + ..._newOutputEntries.mapWithIndex(_outputEntryRow), + _feeOutputRow() + ], + ), + IconButton(onPressed: _addOutputEntry, icon: const Icon(Icons.add)) + ], + ); + } + + static const _outputTableHeader = [ + DataColumn( + label: Expanded( + child: Text( + 'Quantity', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ), + ), + DataColumn( + label: Expanded( + child: Text( + 'Lock Address', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ), + ), + DataColumn( + label: Expanded( + child: Text( + 'Remove', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ), + ), + ]; + + DataRow _outputEntryRow((String, String) entry, int index) { + return DataRow( + cells: [ + DataCell(TextFormField( + initialValue: entry.$1, + onChanged: (value) => _updateOutputEntryQuantity(index, value), + )), + DataCell(TextFormField( + initialValue: entry.$2, + onChanged: (value) => _updateOutputEntryAddress(index, value), + )), + DataCell( + IconButton( + icon: const Icon(Icons.delete), + onPressed: () => _deleteOutputEntry(index), + ), + ), + ], + ); + } + + DataRow _feeOutputRow() { + Int128 outputSum = BigInt.zero.toInt128(); + String? errorText; + for (final t in _newOutputEntries) { + try { + final parsed = BigInt.parse(t.$1).toInt128(); + outputSum += parsed; + } catch (_) { + errorText = "?"; + break; + } + } + + return DataRow(cells: [ + DataCell( + Text(errorText ?? (_inputSum() - outputSum).toBigInt().toString())), + const DataCell(Text("Tip")), + const DataCell(IconButton( + icon: Icon(Icons.cancel), + onPressed: null, + )), + ]); + } + + _updateOutputEntryQuantity(int index, String value) { + setState(() { + final (_, a) = _newOutputEntries[index]; + _newOutputEntries[index] = (value, a); + }); + } + + _updateOutputEntryAddress(int index, String value) { + setState(() { + final (v, _) = _newOutputEntries[index]; + _newOutputEntries[index] = (v, value); + }); + } + + _addOutputEntry() { + setState(() { + _newOutputEntries.add(const ("100", "")); + }); + } + + _deleteOutputEntry(int index) { + setState(() { + _newOutputEntries.removeAt(index); + }); + } + + Widget _inputsTile() { + return Container( + child: widget.utxos.isEmpty + ? const Text("Empty wallet") + : DataTable( + columns: header, + rows: widget.utxos.entries.map(_inputEntryRow).toList(), + )); + } + + DataRow _inputEntryRow(MapEntry entry) { + final value = entry.value.transactionOutput.value; + final quantityString = value.quantity?.show ?? "-"; + return DataRow( + cells: [ + DataCell(Text(quantityString, style: const TextStyle(fontSize: 12))), + DataCell(TextButton( + onPressed: () { + Clipboard.setData(ClipboardData( + text: AddressCodecs.encode( + entry.value.transactionOutput.address))); + }, + child: Text( + AddressCodecs.encode(entry.value.transactionOutput.address), + style: const TextStyle(fontSize: 12)), + )), + DataCell(Checkbox( + value: _selectedInputs.contains(entry.key), + onChanged: (newValue) => + _updateInputEntry(entry.key, newValue ?? false))), + ], + ); + } + + _updateInputEntry(TransactionOutputAddress ref, bool retain) { + setState(() { + if (retain) { + _selectedInputs.add(ref); + } else { + _selectedInputs.remove(ref); + } + }); + } + + static const header = [ + DataColumn( + label: Expanded( + child: Text( + 'Quantity', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ), + ), + DataColumn( + label: Expanded( + child: Text( + 'Lock Address', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ), + ), + DataColumn( + label: Expanded( + child: Text( + 'Selected', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ), + ), + ]; +} diff --git a/lib/features/peg_in/logic/bridge_api.dart b/lib/features/peg_in/logic/bridge_api_interface.dart similarity index 97% rename from lib/features/peg_in/logic/bridge_api.dart rename to lib/features/peg_in/logic/bridge_api_interface.dart index 447db2f..7f8a2e5 100644 --- a/lib/features/peg_in/logic/bridge_api.dart +++ b/lib/features/peg_in/logic/bridge_api_interface.dart @@ -2,10 +2,10 @@ import 'dart:convert'; import 'package:apparatus_wallet/features/peg_in/logic/http_client.dart'; -class BridgeApi { +class BridgeApiInterface { final String baseAddress; - BridgeApi({required this.baseAddress}); + BridgeApiInterface({required this.baseAddress}); Future startSession(StartSessionRequest request) async { final response = await httpClient.post( diff --git a/lib/features/peg_in/providers/bridge_api.dart b/lib/features/peg_in/providers/bridge_api.dart new file mode 100644 index 0000000..30285d9 --- /dev/null +++ b/lib/features/peg_in/providers/bridge_api.dart @@ -0,0 +1,12 @@ +import 'package:apparatus_wallet/features/peg_in/logic/bridge_api_interface.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'bridge_api.g.dart'; + +@riverpod +class BridgeApi extends _$BridgeApi { + @override + BridgeApiInterface build() => + // TODO: Don't hardcode + BridgeApiInterface(baseAddress: "http://localhost:5000"); +} diff --git a/lib/features/peg_in/providers/peg_in.dart b/lib/features/peg_in/providers/peg_in.dart new file mode 100644 index 0000000..e51406f --- /dev/null +++ b/lib/features/peg_in/providers/peg_in.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; + +import 'package:apparatus_wallet/features/peg_in/logic/bridge_api_interface.dart'; +import 'package:apparatus_wallet/features/peg_in/providers/bridge_api.dart'; +import 'package:brambldart/brambldart.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:uuid/uuid.dart'; +import 'package:convert/convert.dart' show hex; + +part 'peg_in.freezed.dart'; +part 'peg_in.g.dart'; + +@riverpod +class PegIn extends _$PegIn { + @override + PegInState build() => PegInState.base(); + + startSession() async { + state = state.copyWith(sessionStarted: true); + final uuid = const Uuid().v4(); + final hashed = hex.encode(sha256.hash(utf8.encode(uuid))); + final request = StartSessionRequest( + pkey: + // TODO: This comes from the bridge demo - should it be replaced? + "0295bb5a3b80eeccb1e38ab2cbac2545e9af6c7012cdc8d53bd276754c54fc2e4a", + sha256: hashed); + try { + final response = await ref.watch(bridgeApiProvider).startSession(request); + state = state.copyWith( + sessionID: response.sessionID, + escrowAddress: response.escrowAddress, + ); + } catch (e) { + state = state.copyWith(error: "An error occurred. Lol! $e"); + } + } + + btcDeposited() { + state = state.copyWith(btcDeposited: true); + final bridgeApi = ref.watch(bridgeApiProvider); + final sub = Stream.periodic(const Duration(seconds: 5)) + .asyncMap((_) => bridgeApi.getMintingStatus(state.sessionID!)) + .where((status) => + // TODO: If null, raise error? + status != null && + status is MintingStatus_PeginSessionWaitingForRedemption) + // Using where + take(1) instead of firstWhere to avoid losing the cancelation benefits of the stream + .take(1) + .listen((_) { + state = state.copyWith(tBtcMinted: true); + }); + sub.onError( + (e) => state = state.copyWith(error: "An error occurred. Lol! $e")); + ref.onDispose(sub.cancel); + } + + tBtcAccepted() { + state = PegInState.base(); + } +} + +@freezed +class PegInState with _$PegInState { + const factory PegInState({ + required bool sessionStarted, + required String? sessionID, + required String? escrowAddress, + required bool btcDeposited, + required bool tBtcMinted, + required String? error, + }) = _PegInState; + + factory PegInState.base() => const PegInState( + sessionStarted: false, + sessionID: null, + escrowAddress: null, + btcDeposited: false, + tBtcMinted: false, + error: null, + ); +} diff --git a/lib/features/peg_in/widgets/peg_in.dart b/lib/features/peg_in/widgets/peg_in.dart index e3c3473..fab7cd5 100644 --- a/lib/features/peg_in/widgets/peg_in.dart +++ b/lib/features/peg_in/widgets/peg_in.dart @@ -1,82 +1,56 @@ -import 'dart:convert'; - -import 'package:crypto/crypto.dart'; +import 'package:apparatus_wallet/features/peg_in/providers/peg_in.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'package:apparatus_wallet/features/peg_in/logic/bridge_api.dart'; -import 'package:uuid/uuid.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; /// A scaffolded page which allows a user to begin a new Peg-In session -class PegInPage extends StatefulWidget { +class PegInPage extends HookConsumerWidget { const PegInPage({super.key}); @override - State createState() => _PegInPageState(); -} - -class _PegInPageState extends State { - bool started = false; - String? error; - StartSessionResponse? session; - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: const Text("Acquire tBTC"), ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - (session != null) - ? continueButton(context, session!) - : (error != null) - ? errorWidget(error!) - : started - ? const CircularProgressIndicator() - : startSessionButton(context), - ], - ), - ), + body: Center(child: body(ref)), ); } - Widget startSessionButton(BuildContext context) { - final bridgeApi = context.watch(); - return TextButton.icon( - onPressed: () => onStartSession(bridgeApi), - icon: const Icon(Icons.start), - label: const Text("Start Session")); - } - - void onStartSession(BridgeApi bridgeApi) async { - setState(() => started = true); - final uuid = const Uuid().v4(); - final hashed = sha256.convert(utf8.encode(uuid)).toString(); - final request = StartSessionRequest( - pkey: - "0295bb5a3b80eeccb1e38ab2cbac2545e9af6c7012cdc8d53bd276754c54fc2e4a", - sha256: hashed); - try { - final response = await bridgeApi.startSession(request); - setState(() => session = response); - } catch (e) { - setState(() => error = "An error occurred. Lol! $e"); + Widget body(WidgetRef ref) { + final state = ref.watch(pegInProvider); + if (state.sessionStarted) { + if (state.escrowAddress == null || state.sessionID == null) { + return const CircularProgressIndicator(); + } else { + if (state.tBtcMinted) { + return PegInClaimFundsPage( + onAccepted: () => + ref.watch(pegInProvider.notifier).tBtcAccepted()); + } + if (state.btcDeposited) { + return PegInAwaitingFundsStage(sessionID: state.sessionID!); + } else { + return PegInDepositFundsStage( + sessionID: state.sessionID!, + escrowAddress: state.escrowAddress!, + onDeposit: () => ref.watch(pegInProvider.notifier).btcDeposited(), + ); + } + } + } else { + return startSessionButton(ref); } } - Widget continueButton(BuildContext context, StartSessionResponse session) => - TextButton.icon( - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PegInDepositFundsPage( - sessionID: session.sessionID, - escrowAddress: session.escrowAddress))), - icon: const Icon(Icons.forward), - label: const Text("Continue")); + Widget startSessionButton(WidgetRef ref) { + return TextButton.icon( + onPressed: () => ref.read(pegInProvider.notifier).startSession(), + icon: const Icon(Icons.start), + label: const Text("Start Session"), + ); + } Widget errorWidget(String message) => Text(message, style: const TextStyle( @@ -84,152 +58,80 @@ class _PegInPageState extends State { } /// A scaffolded page instructing users where to deposit BTC funds -class PegInDepositFundsPage extends StatefulWidget { - const PegInDepositFundsPage( - {super.key, required this.sessionID, required this.escrowAddress}); +class PegInDepositFundsStage extends StatelessWidget { + const PegInDepositFundsStage( + {super.key, + required this.sessionID, + required this.escrowAddress, + required this.onDeposit}); final String sessionID; final String escrowAddress; + final Function() onDeposit; @override - State createState() => _PegInDepositFundsPageState(); -} - -class _PegInDepositFundsPageState extends State { - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: const Text("Acquire tBTC: Deposit BTC Funds"), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Please send BTC to the following address.', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), - TextButton.icon( - onPressed: () => Clipboard.setData( - ClipboardData(text: widget.escrowAddress)), - icon: const Icon(Icons.copy), - label: Text(widget.escrowAddress, - style: const TextStyle( - fontSize: 14, fontWeight: FontWeight.w100)), - ), - TextButton.icon( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PegInAwaitingFundsPage( - sessionID: widget.sessionID))); - }, - icon: const Icon(Icons.start), - label: const Text("Next")), - ], - ), + Widget build(BuildContext context) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Please send BTC to the following address.', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + TextButton.icon( + onPressed: () => + Clipboard.setData(ClipboardData(text: escrowAddress)), + icon: const Icon(Icons.copy), + label: Text(escrowAddress, + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w100)), + ), + TextButton.icon( + onPressed: onDeposit, + icon: const Icon(Icons.start), + label: const Text("Next"), + ), + ], ), ); } /// A scaffolded page which waits for the API to indicate funds have been deposited -class PegInAwaitingFundsPage extends StatefulWidget { - const PegInAwaitingFundsPage({super.key, required this.sessionID}); +class PegInAwaitingFundsStage extends StatelessWidget { + const PegInAwaitingFundsStage({super.key, required this.sessionID}); final String sessionID; @override - State createState() => _PegInAwaitingFundsPageState(); -} - -class _PegInAwaitingFundsPageState extends State { - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: const Text("Acquire tBTC: Awaiting BTC Transfer"), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Please wait while we confirm the BTC transfer.', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), - FutureBuilder( - future: awaitRedemption(context), - builder: (context, snapshot) => snapshot.hasError - ? errorOccurred(snapshot.error) - : snapshot.hasData - ? successButton - : waiting, - ), - ], + Widget build(BuildContext context) => const Column( + children: [ + Center( + child: Text('Please wait while we confirm the BTC transfer.', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), ), - ), + CircularProgressIndicator(), + ], ); - - Future awaitRedemption( - BuildContext context) async { - BridgeApi client = context.watch(); - MintingStatus? status = await client.getMintingStatus(widget.sessionID); - while (status is! MintingStatus_PeginSessionWaitingForRedemption) { - ArgumentError.checkNotNull(status); - await Future.delayed(const Duration(seconds: 5)); - status = await client.getMintingStatus(widget.sessionID); - } - return status; - } - - Widget get waiting => const CircularProgressIndicator(); - - Widget get successButton => TextButton.icon( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const PegInClaimFundsPage())); - }, - icon: const Icon(Icons.wallet), - label: const Text("Next")); - Widget errorOccurred(Object? e) => Text('An error occurred. Lol. $e', - style: const TextStyle( - fontSize: 14, fontWeight: FontWeight.bold, color: Colors.red)); } /// A scaffolded page instructing users that tBTC funds are now available in their wallet -class PegInClaimFundsPage extends StatefulWidget { - const PegInClaimFundsPage({super.key}); +class PegInClaimFundsPage extends StatelessWidget { + const PegInClaimFundsPage({super.key, required this.onAccepted}); - @override - State createState() => _PegInClaimFundsPageState(); -} + final Function() onAccepted; -class _PegInClaimFundsPageState extends State { @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: const Text("Acquire tBTC: Done"), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'Your tBTC is ready and available for transfer in your wallet.', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), - TextButton.icon( - onPressed: () { - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute( - builder: (context) => const PegInPage()), - (_) => true); - }, - icon: const Icon(Icons.done), - label: const Text("Back")), - ], - ), + Widget build(BuildContext context) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Your tBTC is ready and available for transfer in your wallet.', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + TextButton.icon( + onPressed: onAccepted, + icon: const Icon(Icons.done), + label: const Text("Back"), + ), + ], ), ); } diff --git a/lib/router.dart b/lib/router.dart index a39e745..ea4213a 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,17 +1,13 @@ +import 'package:apparatus_wallet/features/bridge/widgets/utxos_view.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:provider/provider.dart'; import 'package:apparatus_wallet/features/bridge/bridge_ui.dart'; -import 'package:apparatus_wallet/features/peg_in/logic/bridge_api.dart'; import 'package:apparatus_wallet/features/peg_in/widgets/peg_in.dart'; /// const routes # TODO move to own file once we get more routes const homeRoute = '/'; const swapRoute = '/swap'; - -final providedNavigator = - GlobalKey(); //TODO: remove with provider refactor +const walletRoute = '/wallet'; // GoRouter configuration final router = GoRouter( @@ -21,17 +17,15 @@ final router = GoRouter( routes: [ GoRoute( path: homeRoute, - builder: (context, state) => Provider( - create: (context) => BridgeApi(baseAddress: "http://localhost:4000"), - child: Navigator( - key: providedNavigator, - onGenerateRoute: (settings) => MaterialPageRoute( - builder: (context) => const PegInPage(), - settings: settings))), + builder: (context, state) => const PegInPage(), ), GoRoute( path: swapRoute, builder: (context, state) => const BridgeUi(), ), + GoRoute( + path: walletRoute, + builder: (context, state) => const UtxosView(), + ), ], ); diff --git a/lib/utils/rpc/channel_factory.dart b/lib/utils/rpc/channel_factory.dart new file mode 100644 index 0000000..e879a2e --- /dev/null +++ b/lib/utils/rpc/channel_factory.dart @@ -0,0 +1,2 @@ +export 'channel_factory_non_web.dart' + if (dart.library.html) 'channel_factory_web.dart'; diff --git a/lib/utils/rpc/channel_factory_non_web.dart b/lib/utils/rpc/channel_factory_non_web.dart new file mode 100644 index 0000000..c37cc48 --- /dev/null +++ b/lib/utils/rpc/channel_factory_non_web.dart @@ -0,0 +1,11 @@ +import 'package:grpc/grpc_connection_interface.dart'; +import 'package:grpc/grpc.dart'; + +ClientChannelBase makeChannel(String host, int port, bool secure) { + return ClientChannel(host, + port: port, + options: ChannelOptions( + credentials: secure + ? const ChannelCredentials.secure() + : const ChannelCredentials.insecure())); +} diff --git a/lib/utils/rpc/channel_factory_web.dart b/lib/utils/rpc/channel_factory_web.dart new file mode 100644 index 0000000..e88ddac --- /dev/null +++ b/lib/utils/rpc/channel_factory_web.dart @@ -0,0 +1,8 @@ +import 'package:grpc/grpc_connection_interface.dart'; +import 'package:grpc/grpc_web.dart'; + +ClientChannelBase makeChannel(String host, int port, bool secure) { + final prefix = secure ? "https://" : "http://"; + final uri = "$prefix$host:$port"; + return GrpcWebClientChannel.xhr(Uri.parse(uri)); +} diff --git a/pubspec.yaml b/pubspec.yaml index 2916eee..63a763e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,10 +32,24 @@ dependencies: fpdart: ^1.1.0 fetch_client: ^1.1.2 http: ^1.2.1 #TODO: Migrate to fetch_client + fixnum: ^1.1.0 #Misc - uuid: ^4.4.0 + uuid: ^4.2.1 crypto: ^3.0.3 + grpc: ^3.2.4 + + # Blockchain + brambldart: + path: ../BramblDart + # ^2.0.0-beta.0 + topl_common: ^2.1.0 + servicekit: + path: ../dart_service_kit + convert: ^3.1.1 + # git: + # url: https://github.com/Topl/dart_service_kit.git + # ref: beta-0-updates dev_dependencies: flutter_test: