From a323bab3d55976ef10c3fbcf8404ab252918f8ca Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 5 Oct 2026 08:31:35 -0500 Subject: [PATCH] feat: add recovery amount to transaction log --- lib/db.dart | 4 +- lib/frb_generated.dart | 6 + lib/l10n/app_en.arb | 6 + lib/l10n/app_es.arb | 2 + lib/multimint.dart | 7 +- lib/multimint.freezed.dart | 32 ++++ lib/widgets/transaction_details.dart | 2 + lib/widgets/transaction_item.dart | 25 ++- rust/ecashapp/src/db.rs | 24 +++ rust/ecashapp/src/frb_generated.rs | 7 + rust/ecashapp/src/multimint.rs | 218 +++++++++++++++++++++++++-- 11 files changed, 320 insertions(+), 13 deletions(-) diff --git a/lib/db.dart b/lib/db.dart index 38236f36..ebe20af9 100644 --- a/lib/db.dart +++ b/lib/db.dart @@ -7,8 +7,8 @@ import 'frb_generated.dart'; import 'lib.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; -// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `BitcoinDisplayKey`, `BtcPriceKey`, `BtcPrice`, `BtcPricesKey`, `BtcPrices`, `ContactKeyPrefix`, `ContactKey`, `ContactSyncConfigKey`, `DbKeyPrefix`, `FederationBackupKey`, `FederationConfigKeyPrefix`, `FederationConfigKey`, `FederationMetaKeyPrefix`, `FederationMetaKey`, `FederationMetaV1`, `FederationOrderKey`, `FederationOrder`, `FiatCurrencyKey`, `GuardianSessionFederationPrefix`, `GuardianSessionKey`, `GuardianSession`, `LightningAddressKeyPrefix`, `NostrRelaysKeyPrefix`, `NostrRelaysKey`, `NostrWalletConnectConfig`, `NostrWalletConnectKeyPrefix`, `NostrWalletConnectKey`, `NostrWalletConnectV2Config`, `NostrWalletConnectV2KeyPrefix`, `NostrWalletConnectV2Key`, `NwcLimitsKey`, `NwcLimits`, `NwcSpendWindowKey`, `NwcSpendWindow`, `PinAttemptsKey`, `PinAttempts`, `PinCodeHashKey`, `PinCredentialKey`, `RequirePinForSpendingKey`, `SchemaVersionKey`, `SeedPhraseAckKey`, `ShowMsatsKey`, `Timestamp`, `WalletV2PendingDepositFederationPrefix`, `WalletV2PendingDepositKey` -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `cmp`, `cmp`, `cmp`, `cmp`, `cmp`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `hash`, `hash`, `hash`, `hash`, `partial_cmp`, `partial_cmp`, `partial_cmp`, `partial_cmp`, `partial_cmp` +// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `BitcoinDisplayKey`, `BtcPriceKey`, `BtcPrice`, `BtcPricesKey`, `BtcPrices`, `ContactKeyPrefix`, `ContactKey`, `ContactSyncConfigKey`, `DbKeyPrefix`, `FederationBackupKey`, `FederationConfigKeyPrefix`, `FederationConfigKey`, `FederationMetaKeyPrefix`, `FederationMetaKey`, `FederationMetaV1`, `FederationOrderKey`, `FederationOrder`, `FederationRecoveryKey`, `FederationRecovery`, `FiatCurrencyKey`, `GuardianSessionFederationPrefix`, `GuardianSessionKey`, `GuardianSession`, `LightningAddressKeyPrefix`, `NostrRelaysKeyPrefix`, `NostrRelaysKey`, `NostrWalletConnectConfig`, `NostrWalletConnectKeyPrefix`, `NostrWalletConnectKey`, `NostrWalletConnectV2Config`, `NostrWalletConnectV2KeyPrefix`, `NostrWalletConnectV2Key`, `NwcLimitsKey`, `NwcLimits`, `NwcSpendWindowKey`, `NwcSpendWindow`, `PinAttemptsKey`, `PinAttempts`, `PinCodeHashKey`, `PinCredentialKey`, `RequirePinForSpendingKey`, `SchemaVersionKey`, `SeedPhraseAckKey`, `ShowMsatsKey`, `Timestamp`, `WalletV2PendingDepositFederationPrefix`, `WalletV2PendingDepositKey` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `cmp`, `cmp`, `cmp`, `cmp`, `cmp`, `cmp`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `hash`, `hash`, `hash`, `hash`, `hash`, `partial_cmp`, `partial_cmp`, `partial_cmp`, `partial_cmp`, `partial_cmp`, `partial_cmp` // Rust type: RustOpaqueMoi> abstract class FederationConfig implements RustOpaqueInterface { diff --git a/lib/frb_generated.dart b/lib/frb_generated.dart index 4a18f71d..464ca0ec 100644 --- a/lib/frb_generated.dart +++ b/lib/frb_generated.dart @@ -15409,6 +15409,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { oobNotes: dco_decode_String(raw[1]), fees: dco_decode_u_64(raw[2]), ); + case 7: + return TransactionKind_Recovery(); default: throw Exception("unreachable"); } @@ -18588,6 +18590,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { oobNotes: var_oobNotes, fees: var_fees, ); + case 7: + return TransactionKind_Recovery(); default: throw UnimplementedError(''); } @@ -21729,6 +21733,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_32(6, serializer); sse_encode_String(oobNotes, serializer); sse_encode_u_64(fees, serializer); + case TransactionKind_Recovery(): + sse_encode_i_32(7, serializer); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5e47d285..f50ec9d0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1639,6 +1639,9 @@ "onchainSendTitle": "Onchain Send", "@onchainSendTitle": {}, + "ecashRecoveryTitle": "Ecash Recovery", + "@ecashRecoveryTitle": {}, + "checkClaimStatus": "Check Claim Status", "@checkClaimStatus": {}, @@ -1852,6 +1855,9 @@ "txReceived": "Received", "@txReceived": {}, + "txRecovered": "Recovered", + "@txRecovered": {}, + "testNetworkMessage": "This is a test network and is not worth anything.", "@testNetworkMessage": {}, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 4c03b3a0..b836b1c1 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -461,6 +461,7 @@ "ecashSendTitle": "Envío Ecash", "onchainReceiveTitle": "Recepción en cadena", "onchainSendTitle": "Envío en cadena", + "ecashRecoveryTitle": "Recuperación Ecash", "checkClaimStatus": "Verificar estado de reclamo", "redeemEcash": "Canjear Ecash", "ecashHasBeenClaimed": "Este Ecash ha sido reclamado", @@ -520,6 +521,7 @@ "txSent": "Enviado", "txReceived": "Recibido", + "txRecovered": "Recuperado", "testNetworkMessage": "Esta es una red de prueba y no tiene valor.", "yourRecoveryPhrase": "Tu frase de recuperación", "seedPhraseAckWarning": "Por favor, confirma que has escrito tu frase semilla manteniendo presionado el botón de abajo", diff --git a/lib/multimint.dart b/lib/multimint.dart index 34bb1778..afa715ff 100644 --- a/lib/multimint.dart +++ b/lib/multimint.dart @@ -11,7 +11,7 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:freezed_annotation/freezed_annotation.dart' hide protected; part 'multimint.freezed.dart'; -// These functions are ignored because they are not marked as `pub`: `aggregate_recovery_progress`, `await_receive_lnv1`, `await_receive_lnv2`, `await_send_lnv1`, `await_send_lnv2`, `backup`, `build_client`, `cache_btc_price`, `cache_federation_meta`, `check_for_update`, `compute_send_fees`, `extract_recipient_pk_from_lnv2_lnurl`, `fetch_guardian_session_counts`, `finish_active_subscriptions`, `from_cached`, `from_json`, `from_peg_out_fees`, `get_client_database`, `get_client`, `get_ecash_amount_from_meta`, `get_expiry_timestamp`, `get_federation`, `get_lnv1_amount_from_meta`, `get_lnv1_receive_tx`, `get_lnv1_send_tx`, `get_lnv2_amount_from_meta`, `get_mintv2_receive_amount`, `get_or_build_temp_client`, `get_recurringd_federations`, `get_successor_invite`, `get_url`, `gross_invoice_for_contract`, `guardian_admin_api`, `guardian_bitcoin_status`, `guardian_lnv2_module_api`, `guardian_meta_consensus`, `guardian_meta_module_api`, `guardian_meta_submit`, `guardian_session_statuses`, `init_recovery_progress_cache`, `invoice_is_loopback`, `is_invalid_guardian_auth`, `is_newer_version`, `join_federation_inner`, `join_federation_on`, `list_gateways`, `lnv1_update_gateway_cache`, `lnv2_gateways`, `load_clients`, `load_guardian_sessions`, `meta_fields_after_fetch`, `migrate_federation_meta_v3`, `pay_lnv1`, `pay_lnv2`, `read_meta_msats`, `read_meta_string`, `read_meta_u64`, `read_meta_url`, `receive_lnv1`, `receive_lnv2`, `record_guardian_session`, `recovery_module_for_kind`, `refresh_guardian_sessions`, `register_federation`, `release_ln_address`, `remove_recovery_progress_cache`, `resolve`, `retire_recovery_client`, `run_cache_stage`, `run_migrations`, `send_federation_fee`, `shutdown_client`, `sign_challenge`, `skip_unrenderable`, `solve_gross_for_net`, `spawn_await_ecash_reissue`, `spawn_await_ecash_send`, `spawn_await_mintv2_receive`, `spawn_await_receive`, `spawn_await_recurringd_receive`, `spawn_await_send`, `spawn_await_withdraw`, `spawn_backfill_recipient_pk`, `spawn_cache_task`, `spawn_federation_teardown`, `spawn_lnv2_event_listener`, `spawn_recovery_progress`, `spawn_recurring_invoice_listener`, `update_recovery_progress_cache`, `validate_receive_fee`, `wait_for_recovery`, `wallet_network` +// These functions are ignored because they are not marked as `pub`: `aggregate_recovery_progress`, `await_receive_lnv1`, `await_receive_lnv2`, `await_send_lnv1`, `await_send_lnv2`, `backup`, `build_client`, `cache_btc_price`, `cache_federation_meta`, `check_for_update`, `compute_send_fees`, `extract_recipient_pk_from_lnv2_lnurl`, `fetch_guardian_session_counts`, `finish_active_subscriptions`, `from_cached`, `from_json`, `from_peg_out_fees`, `get_client_database`, `get_client`, `get_ecash_amount_from_meta`, `get_expiry_timestamp`, `get_federation`, `get_lnv1_amount_from_meta`, `get_lnv1_receive_tx`, `get_lnv1_send_tx`, `get_lnv2_amount_from_meta`, `get_mintv2_receive_amount`, `get_or_build_temp_client`, `get_recurringd_federations`, `get_successor_invite`, `get_url`, `gross_invoice_for_contract`, `guardian_admin_api`, `guardian_bitcoin_status`, `guardian_lnv2_module_api`, `guardian_meta_consensus`, `guardian_meta_module_api`, `guardian_meta_submit`, `guardian_session_statuses`, `init_recovery_progress_cache`, `insert_recovery_row`, `invoice_is_loopback`, `is_invalid_guardian_auth`, `is_newer_version`, `join_federation_inner`, `join_federation_on`, `list_gateways`, `lnv1_update_gateway_cache`, `lnv2_gateways`, `load_clients`, `load_guardian_sessions`, `meta_fields_after_fetch`, `migrate_federation_meta_v3`, `pay_lnv1`, `pay_lnv2`, `read_meta_msats`, `read_meta_string`, `read_meta_u64`, `read_meta_url`, `receive_lnv1`, `receive_lnv2`, `record_guardian_session`, `recovery_module_for_kind`, `refresh_guardian_sessions`, `register_federation`, `release_ln_address`, `remove_recovery_progress_cache`, `resolve`, `retire_recovery_client`, `run_cache_stage`, `run_migrations`, `send_federation_fee`, `shutdown_client`, `sign_challenge`, `skip_unrenderable`, `solve_gross_for_net`, `spawn_await_ecash_reissue`, `spawn_await_ecash_send`, `spawn_await_mintv2_receive`, `spawn_await_receive`, `spawn_await_recurringd_receive`, `spawn_await_send`, `spawn_await_withdraw`, `spawn_backfill_recipient_pk`, `spawn_cache_task`, `spawn_federation_teardown`, `spawn_lnv2_event_listener`, `spawn_recovery_progress`, `spawn_recurring_invoice_listener`, `update_recovery_progress_cache`, `validate_receive_fee`, `wait_for_recovery`, `wallet_network` // These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `ClientType`, `JoinedFederation`, `LNAddressRegisterRequest`, `LNAddressRemoveRequest`, `MetaFetch`, `MetaFields`, `OnChainWithdrawalMeta`, `SessionPeers`, `WrappedEcash` // These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `cmp`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_decode_partial_from_finite_reader`, `consensus_encode`, `consensus_encode`, `consensus_encode`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `partial_cmp` // These functions are ignored (category: IgnoreBecauseExplicitAttribute): `federation_network` @@ -1725,6 +1725,11 @@ sealed class TransactionKind with _$TransactionKind { required String oobNotes, required BigInt fees, }) = TransactionKind_EcashSend; + + /// Ecash restored by a seed-phrase recovery, recorded once per federation + /// by `wait_for_recovery`. Not a payment, so it carries nothing beyond the + /// amount and time. + const factory TransactionKind.recovery() = TransactionKind_Recovery; } class Utxo { diff --git a/lib/multimint.freezed.dart b/lib/multimint.freezed.dart index 55b5c489..51bc1e9c 100644 --- a/lib/multimint.freezed.dart +++ b/lib/multimint.freezed.dart @@ -2983,4 +2983,36 @@ as BigInt, } +/// @nodoc + + +class TransactionKind_Recovery extends TransactionKind { + const TransactionKind_Recovery(): super._(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TransactionKind_Recovery); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'TransactionKind.recovery()'; +} + + +} + + + + // dart format on diff --git a/lib/widgets/transaction_details.dart b/lib/widgets/transaction_details.dart index 0e0b2cad..595be0ac 100644 --- a/lib/widgets/transaction_details.dart +++ b/lib/widgets/transaction_details.dart @@ -48,6 +48,8 @@ class _TransactionDetailsState extends State { return context.l10n.onchainReceiveTitle; case TransactionKind_OnchainSend(): return context.l10n.onchainSendTitle; + case TransactionKind_Recovery(): + return context.l10n.ecashRecoveryTitle; } } diff --git a/lib/widgets/transaction_item.dart b/lib/widgets/transaction_item.dart index 988f1d17..ae84a34d 100644 --- a/lib/widgets/transaction_item.dart +++ b/lib/widgets/transaction_item.dart @@ -324,6 +324,22 @@ class TransactionItem extends StatelessWidget { }, ); break; + case TransactionKind_Recovery(): + showAppModalBottomSheet( + context: context, + childBuilder: () async { + return TransactionDetails( + tx: tx, + details: { + TransactionDetailKeys.amount: formattedAmount, + TransactionDetailKeys.timestamp: formattedDate, + }, + icon: icon, + fed: fed, + ); + }, + ); + break; } } @@ -332,7 +348,9 @@ class TransactionItem extends StatelessWidget { final bitcoinDisplay = context.select( (prefs) => prefs.bitcoinDisplay, ); + final isRecovery = tx.kind is TransactionKind_Recovery; final isIncoming = + isRecovery || tx.kind is TransactionKind_LightningReceive || tx.kind is TransactionKind_OnchainReceive || tx.kind is TransactionKind_EcashReceive || @@ -356,6 +374,9 @@ class TransactionItem extends StatelessWidget { case TransactionKind_EcashSend(): moduleIcon = Icons.currency_bitcoin; break; + case TransactionKind_Recovery(): + moduleIcon = Icons.restore; + break; } final amountStyle = TextStyle( @@ -384,7 +405,9 @@ class TransactionItem extends StatelessWidget { ), ), title: Text( - isIncoming ? context.l10n.txReceived : context.l10n.txSent, + isRecovery + ? context.l10n.txRecovered + : (isIncoming ? context.l10n.txReceived : context.l10n.txSent), style: Theme.of(context).textTheme.bodyMedium, ), subtitle: Text( diff --git a/rust/ecashapp/src/db.rs b/rust/ecashapp/src/db.rs index dd50d5a7..187e6a1b 100644 --- a/rust/ecashapp/src/db.rs +++ b/rust/ecashapp/src/db.rs @@ -104,6 +104,7 @@ pub(crate) enum DbKeyPrefix { NwcLimits = 0x19, NwcSpendWindow = 0x1A, GuardianSession = 0x1B, + FederationRecovery = 0x1C, } #[derive(Debug, Clone, Encodable, Decodable, Eq, PartialEq, Hash, Ord, PartialOrd)] @@ -650,6 +651,29 @@ impl_db_lookup!( query_prefix = GuardianSessionFederationPrefix, ); +#[derive(Debug, Clone, Encodable, Decodable, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub(crate) struct FederationRecoveryKey { + pub(crate) federation_id: FederationId, +} + +/// What a seed-phrase recovery restored in one federation, shown as a row in +/// its ecash history. +/// +/// Recovery rebuilds the balance but not the operation log, so without this +/// record the recovered funds would sit in the wallet with nothing in the +/// history to account for them. +#[derive(Debug, Clone, PartialEq, Eq, Encodable, Decodable)] +pub(crate) struct FederationRecovery { + pub(crate) amount_msats: u64, + pub(crate) recovered_at: Timestamp, +} + +impl_db_record!( + key = FederationRecoveryKey, + value = FederationRecovery, + db_prefix = DbKeyPrefix::FederationRecovery, +); + #[cfg(test)] mod tests { use std::time::{Duration, UNIX_EPOCH}; diff --git a/rust/ecashapp/src/frb_generated.rs b/rust/ecashapp/src/frb_generated.rs index 3cd53780..9fb266bb 100644 --- a/rust/ecashapp/src/frb_generated.rs +++ b/rust/ecashapp/src/frb_generated.rs @@ -18373,6 +18373,9 @@ impl SseDecode for crate::multimint::TransactionKind { fees: var_fees, }; } + 7 => { + return crate::multimint::TransactionKind::Recovery; + } _ => { unimplemented!(""); } @@ -21245,6 +21248,7 @@ impl flutter_rust_bridge::IntoDart for crate::multimint::TransactionKind { fees.into_into_dart().into_dart(), ] .into_dart(), + crate::multimint::TransactionKind::Recovery => [7.into_dart()].into_dart(), _ => { unimplemented!(""); } @@ -23454,6 +23458,9 @@ impl SseEncode for crate::multimint::TransactionKind { ::sse_encode(oob_notes, serializer); ::sse_encode(fees, serializer); } + crate::multimint::TransactionKind::Recovery => { + ::sse_encode(7, serializer); + } _ => { unimplemented!(""); } diff --git a/rust/ecashapp/src/multimint.rs b/rust/ecashapp/src/multimint.rs index f65e1125..c0ba5626 100644 --- a/rust/ecashapp/src/multimint.rs +++ b/rust/ecashapp/src/multimint.rs @@ -110,9 +110,10 @@ use crate::{ db::{ BitcoinDisplay, BitcoinDisplayKey, BtcPrice, BtcPriceKey, BtcPrices, BtcPricesKey, Connector, ContactSyncConfigKey, FederationBackupKey, FederationMetaKey, - FederationMetaKeyPrefix, FederationMetaV1, FiatCurrency, FiatCurrencyKey, GuardianSession, - GuardianSessionFederationPrefix, GuardianSessionKey, LightningAddressConfig, - LightningAddressKey, LightningAddressKeyPrefix, SchemaVersionKey, Timestamp, + FederationMetaKeyPrefix, FederationMetaV1, FederationRecovery, FederationRecoveryKey, + FiatCurrency, FiatCurrencyKey, GuardianSession, GuardianSessionFederationPrefix, + GuardianSessionKey, LightningAddressConfig, LightningAddressKey, LightningAddressKeyPrefix, + SchemaVersionKey, Timestamp, }, error_to_flutter, get_nostr_client, info_to_flutter, payment_error_to_flutter, wallet::WalletHandler, @@ -124,6 +125,9 @@ use crate::{ /// `MAX_INVOICE_EXPIRY_SECS` (`60 * 60 * 24`). Raising this breaks LNv2 /// receives. const DEFAULT_EXPIRY_TIME_SECS: u32 = 86400; +/// How many operations one `transactions` call collects. The Dart side treats +/// a page shorter than this as the end of the history. +const TRANSACTION_PAGE_SIZE: usize = 10; /// Well-known meta field: unix timestamp (seconds) after which the guardians /// will shut the federation down. See fedimint /// `docs/meta_fields/federation_expiry_timestamp.md`. @@ -675,6 +679,10 @@ pub enum TransactionKind { oob_notes: String, fees: u64, }, + /// Ecash restored by a seed-phrase recovery, recorded once per federation + /// by `wait_for_recovery`. Not a payment, so it carries nothing beyond the + /// amount and time. + Recovery, } #[derive(Debug, Serialize, Clone, Eq, PartialEq)] @@ -2040,9 +2048,19 @@ impl Multimint { return; } }; - let Ok(response) = client.get(url).send().await else { - error_to_flutter("BTC Price GET returned error").await; - return; + let response = match client.get(url).send().await { + Ok(response) => response, + Err(e) => { + // No response arrived (DNS, connect, TLS or timeout), so there is + // no status code; the cause is in the error's source chain, + // which `reqwest`'s own `Display` leaves out. + error_to_flutter(format!( + "BTC price request failed: {:#}", + anyhow::Error::from(e) + )) + .await; + return; + } }; if response.status().is_success() { @@ -3224,6 +3242,29 @@ impl Multimint { info_to_flutter("Waiting for all active state machines...").await; new_client.wait_for_all_active_state_machines().await?; + // Every recovery passes through here once it has finished, and the + // balance at this point is exactly what it restored: the state machines + // above have settled, and the app offers no payments until + // `RecoveryDone` below. Nothing restored means nothing to show. + match new_client.get_balance_for_btc().await { + Ok(balance) if balance.msats > 0 => { + let mut dbtx = self.db.begin_transaction().await; + dbtx.insert_entry( + &FederationRecoveryKey { federation_id }, + &FederationRecovery { + amount_msats: balance.msats, + recovered_at: Timestamp(SystemTime::now()), + }, + ) + .await; + dbtx.commit_tx().await; + } + Ok(_) => {} + Err(e) => { + error_to_flutter(format!("Could not record the recovered balance: {e}")).await; + } + } + // Attempt to recover Lightning Address before publishing RecoveryDone, // so the UI can display the recovered address when it handles the event. let ln_address_api = "https://ecash.love"; @@ -4425,7 +4466,7 @@ impl Multimint { ), }); - while collected.len() < 10 { + while collected.len() < TRANSACTION_PAGE_SIZE { let page = client .operation_log() .paginate_operations_rev(50, next_key) @@ -4436,7 +4477,7 @@ impl Multimint { } for (key, op_log_val) in &page { - if collected.len() >= 10 { + if collected.len() >= TRANSACTION_PAGE_SIZE { break; } @@ -5035,9 +5076,70 @@ impl Multimint { next_key = page.last().map(|(key, _)| *key); } + let shows_ecash = [fedimint_mint_common::KIND, fedimint_mintv2_common::KIND] + .iter() + .any(|kind| modules.contains(&kind.to_string())); + if shows_ecash { + let recovery = self + .db + .begin_transaction_nc() + .await + .get_value(&FederationRecoveryKey { + federation_id: *federation_id, + }) + .await; + if let Some(recovery) = recovery { + let recovery = Transaction { + kind: TransactionKind::Recovery, + amount: recovery.amount_msats, + timestamp: recovery + .recovered_at + .0 + .duration_since(UNIX_EPOCH) + .expect("Cannot be before unix epoch") + .as_millis() as u64, + // Not an operation, but the UI hands the last row back as + // the cursor, which has to parse as an `OperationId`. + operation_id: vec![0; 32], + }; + Self::insert_recovery_row(&mut collected, recovery, timestamp); + } + } + collected } + /// Slots a federation's recovery row into one page of its history. + /// + /// A page spans everything older than the cursor and, once it holds a full + /// page of operations, no older than its last one. The recovery row goes on + /// the page whose span contains it, so it shows up exactly once however far + /// the user scrolls. It sorts ahead of operations from the same millisecond, + /// which keeps a real operation as the last row, and so as the next cursor, + /// whenever the page has one there. + fn insert_recovery_row( + page: &mut Vec, + recovery: Transaction, + cursor: Option, + ) { + if cursor.is_some_and(|cursor| recovery.timestamp >= cursor) { + return; + } + if page.len() >= TRANSACTION_PAGE_SIZE + && page + .last() + .is_some_and(|oldest| recovery.timestamp < oldest.timestamp) + { + return; + } + + let position = page + .iter() + .position(|tx| tx.timestamp <= recovery.timestamp) + .unwrap_or(page.len()); + page.insert(position, recovery); + } + /// LNv1 has two different operation send types: external (over the Lightning network) and internal (ecash swap) /// In order to check if the "send" was successful or not, we need to check both outcomes. fn get_lnv1_send_tx( @@ -7684,7 +7786,7 @@ mod tests { load_guardian_sessions, meta_fields_after_fetch, migrate_federation_meta_v3, record_guardian_session, recovery_module_for_kind, FederationMeta, FederationSelector, Guardian, GuardianSessionStatus, MetaFetch, MetaFields, Multimint, RecoveryModule, - MAX_GATEWAY_PPM, + Transaction, TransactionKind, MAX_GATEWAY_PPM, TRANSACTION_PAGE_SIZE, }; /// A cached entry from before the guardians went quiet: shutdown announced. @@ -8200,4 +8302,102 @@ mod tests { assert!(result.is_none()); assert!(!result.is_done()); } + + fn history_row(kind: TransactionKind, timestamp: u64) -> Transaction { + Transaction { + kind, + amount: 1_000, + timestamp, + operation_id: vec![0; 32], + } + } + + fn operation(timestamp: u64) -> Transaction { + history_row( + TransactionKind::EcashSend { + oob_notes: String::new(), + fees: 0, + }, + timestamp, + ) + } + + /// Pages through `operations` (newest first) the way the UI does, feeding + /// each page's last row back as the cursor until a short page ends it. + fn page_through(operations: &[u64], recovered_at: u64) -> Vec> { + let mut pages = Vec::new(); + let mut cursor = None; + loop { + let mut page: Vec = operations + .iter() + .filter(|&×tamp| cursor.is_none_or(|cursor| timestamp < cursor)) + .take(TRANSACTION_PAGE_SIZE) + .map(|×tamp| operation(timestamp)) + .collect(); + Multimint::insert_recovery_row( + &mut page, + history_row(TransactionKind::Recovery, recovered_at), + cursor, + ); + + let done = page.len() < TRANSACTION_PAGE_SIZE; + cursor = page.last().map(|tx| tx.timestamp); + pages.push(page); + if done { + return pages; + } + } + } + + fn is_recovery(tx: &Transaction) -> bool { + matches!(tx.kind, TransactionKind::Recovery) + } + + fn recovery_count(pages: &[Vec]) -> usize { + pages.iter().flatten().filter(|tx| is_recovery(tx)).count() + } + + #[test] + fn a_recovery_with_no_operations_is_the_whole_history() { + let pages = page_through(&[], 50); + + assert_eq!(pages.len(), 1); + assert_eq!(recovery_count(&pages), 1); + } + + #[test] + fn a_recovery_older_than_every_operation_comes_last() { + let operations: Vec = (100..125).rev().collect(); + let pages = page_through(&operations, 50); + + assert_eq!(recovery_count(&pages), 1); + assert!(is_recovery(pages.last().unwrap().last().unwrap())); + } + + #[test] + fn a_recovery_on_a_full_page_slots_in_by_time_and_is_not_repeated() { + let operations: Vec = (100..125).rev().collect(); + let pages = page_through(&operations, 117); + + assert_eq!(recovery_count(&pages), 1); + assert_eq!( + pages[0].iter().map(|tx| tx.timestamp).collect::>(), + vec![124, 123, 122, 121, 120, 119, 118, 117, 117, 116, 115] + ); + // Ahead of the operation from the same millisecond, not behind it. + assert!(is_recovery(&pages[0][7])); + assert_eq!(pages[1][0].timestamp, 114); + } + + #[test] + fn a_recovery_that_fills_the_last_page_is_not_repeated() { + // Nine operations plus the recovery make a full page, so the UI asks + // for another with the recovery row itself as the cursor. + let operations: Vec = (100..109).rev().collect(); + let pages = page_through(&operations, 50); + + assert_eq!(pages.len(), 2); + assert!(pages[1].is_empty()); + assert_eq!(recovery_count(&pages), 1); + } }