Skip to content

Commit 6a4b5e4

Browse files
authored
Merge pull request #2253 from BullishNode/bitcoin.org-listing-requirement-risk-disclosure
bitcoin.org listing requirement risk disclosure
2 parents 4f97b86 + f0aab7d commit 6a4b5e4

5 files changed

Lines changed: 390 additions & 0 deletions

File tree

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import 'package:bb_mobile/core/themes/app_theme.dart';
2+
import 'package:bb_mobile/core/utils/build_context_x.dart';
3+
import 'package:bb_mobile/core/widgets/bottom_sheet/x.dart';
4+
import 'package:bb_mobile/core/widgets/text/text.dart';
5+
import 'package:flutter/material.dart';
6+
import 'package:gap/gap.dart';
7+
8+
class DisclosureLink extends StatelessWidget {
9+
const DisclosureLink({
10+
super.key,
11+
required this.label,
12+
required this.semanticLabel,
13+
required this.title,
14+
required this.body,
15+
});
16+
17+
final String label;
18+
final String semanticLabel;
19+
final String title;
20+
final String body;
21+
22+
@override
23+
Widget build(BuildContext context) {
24+
final color = context.appColors.onSurfaceVariant;
25+
26+
return Semantics(
27+
button: true,
28+
label: semanticLabel,
29+
child: Material(
30+
type: MaterialType.transparency,
31+
child: InkWell(
32+
onTap: () =>
33+
DisclosureBottomSheet.show(context, title: title, body: body),
34+
borderRadius: BorderRadius.circular(2),
35+
child: ConstrainedBox(
36+
constraints: const BoxConstraints(minHeight: 44),
37+
child: Row(
38+
mainAxisSize: MainAxisSize.min,
39+
children: [
40+
Icon(Icons.info_outline, size: 16, color: color),
41+
const Gap(6),
42+
Flexible(
43+
child: BBText(
44+
label,
45+
style: context.font.labelSmall,
46+
color: color,
47+
),
48+
),
49+
],
50+
),
51+
),
52+
),
53+
),
54+
);
55+
}
56+
}
57+
58+
class DisclosureBottomSheet extends StatelessWidget {
59+
const DisclosureBottomSheet({
60+
super.key,
61+
required this.title,
62+
required this.body,
63+
});
64+
65+
final String title;
66+
final String body;
67+
68+
static Future<void> show(
69+
BuildContext context, {
70+
required String title,
71+
required String body,
72+
}) {
73+
return BlurredBottomSheet.show(
74+
context: context,
75+
child: DisclosureBottomSheet(title: title, body: body),
76+
);
77+
}
78+
79+
@override
80+
Widget build(BuildContext context) {
81+
return Container(
82+
constraints: BoxConstraints(
83+
maxHeight: MediaQuery.of(context).size.height * 0.82,
84+
),
85+
decoration: BoxDecoration(
86+
color: context.appColors.surface,
87+
borderRadius: const BorderRadius.vertical(top: Radius.circular(8)),
88+
),
89+
child: Column(
90+
mainAxisSize: MainAxisSize.min,
91+
crossAxisAlignment: CrossAxisAlignment.start,
92+
children: [
93+
Padding(
94+
padding: const EdgeInsets.fromLTRB(24, 24, 16, 12),
95+
child: Row(
96+
crossAxisAlignment: CrossAxisAlignment.start,
97+
children: [
98+
Expanded(
99+
child: BBText(title, style: context.font.headlineMedium),
100+
),
101+
IconButton(
102+
tooltip: context.loc.closeDialogButton,
103+
onPressed: () => Navigator.of(context).pop(),
104+
icon: const Icon(Icons.close),
105+
),
106+
],
107+
),
108+
),
109+
Flexible(
110+
child: SingleChildScrollView(
111+
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
112+
child: _DisclosureBody(body),
113+
),
114+
),
115+
],
116+
),
117+
);
118+
}
119+
}
120+
121+
class _DisclosureBody extends StatelessWidget {
122+
const _DisclosureBody(this.body);
123+
124+
final String body;
125+
126+
@override
127+
Widget build(BuildContext context) {
128+
final blocks = body.trim().split(RegExp(r'\n\s*\n'));
129+
final children = <Widget>[];
130+
131+
for (final block in blocks) {
132+
if (block.trim().isEmpty) continue;
133+
if (children.isNotEmpty) {
134+
children.add(Gap(_spacingBefore(block)));
135+
}
136+
children.add(_buildBlock(context, block.trim()));
137+
}
138+
139+
return Column(
140+
crossAxisAlignment: CrossAxisAlignment.stretch,
141+
children: children,
142+
);
143+
}
144+
145+
double _spacingBefore(String block) {
146+
if (block.startsWith('## ')) return 24;
147+
if (block.startsWith('### ') || block.startsWith('> ')) return 16;
148+
return 12;
149+
}
150+
151+
Widget _buildBlock(BuildContext context, String block) {
152+
if (block.startsWith('## ')) {
153+
return Semantics(
154+
header: true,
155+
child: BBText(
156+
block.substring(3),
157+
style: context.font.titleMedium?.copyWith(fontWeight: .w600),
158+
color: context.appColors.text,
159+
),
160+
);
161+
}
162+
163+
if (block.startsWith('### ')) {
164+
return Semantics(
165+
header: true,
166+
child: BBText(
167+
block.substring(4),
168+
style: context.font.bodyLarge?.copyWith(fontWeight: .w600),
169+
color: context.appColors.text,
170+
),
171+
);
172+
}
173+
174+
final lines = block.split('\n');
175+
if (lines.every((line) => line.startsWith('> '))) {
176+
return _buildCallout(
177+
context,
178+
title: lines.first.substring(2),
179+
body: lines.skip(1).map((line) => line.substring(2)).join('\n'),
180+
);
181+
}
182+
183+
if (lines.every((line) => line.startsWith('- '))) {
184+
return _buildBulletList(
185+
context,
186+
lines.map((line) => line.substring(2)).toList(),
187+
);
188+
}
189+
190+
return BBText(
191+
block,
192+
style: context.font.bodyMedium,
193+
color: context.appColors.text,
194+
);
195+
}
196+
197+
Widget _buildCallout(
198+
BuildContext context, {
199+
required String title,
200+
required String body,
201+
}) {
202+
return Container(
203+
padding: const EdgeInsets.all(16),
204+
decoration: BoxDecoration(
205+
color: context.appColors.warningContainer,
206+
border: Border.all(color: context.appColors.warning),
207+
borderRadius: BorderRadius.circular(2),
208+
),
209+
child: Row(
210+
crossAxisAlignment: CrossAxisAlignment.start,
211+
children: [
212+
Icon(
213+
Icons.warning_amber_rounded,
214+
size: 22,
215+
color: context.appColors.warning,
216+
),
217+
const Gap(12),
218+
Expanded(
219+
child: Column(
220+
crossAxisAlignment: CrossAxisAlignment.start,
221+
children: [
222+
BBText(
223+
title,
224+
style: context.font.bodyLarge?.copyWith(fontWeight: .w600),
225+
color: context.appColors.text,
226+
),
227+
const Gap(6),
228+
BBText(
229+
body,
230+
style: context.font.bodyMedium,
231+
color: context.appColors.text,
232+
),
233+
],
234+
),
235+
),
236+
],
237+
),
238+
);
239+
}
240+
241+
Widget _buildBulletList(BuildContext context, List<String> items) {
242+
return Column(
243+
crossAxisAlignment: CrossAxisAlignment.start,
244+
children: [
245+
for (var index = 0; index < items.length; index++) ...[
246+
if (index > 0) const Gap(8),
247+
Row(
248+
crossAxisAlignment: CrossAxisAlignment.start,
249+
children: [
250+
SizedBox(
251+
width: 18,
252+
child: BBText(
253+
'•',
254+
style: context.font.bodyMedium,
255+
color: context.appColors.text,
256+
),
257+
),
258+
Expanded(child: _buildBulletText(context, items[index])),
259+
],
260+
),
261+
],
262+
],
263+
);
264+
}
265+
266+
Widget _buildBulletText(BuildContext context, String item) {
267+
final style = context.font.bodyMedium?.copyWith(
268+
color: context.appColors.text,
269+
);
270+
final match = RegExp(r'^\*\*(.+?)\*\*(.*)$').firstMatch(item);
271+
272+
if (match == null) return Text(item, style: style);
273+
274+
return Text.rich(
275+
TextSpan(
276+
style: style,
277+
children: [
278+
TextSpan(
279+
text: match.group(1),
280+
style: style?.copyWith(fontWeight: .w600),
281+
),
282+
TextSpan(text: match.group(2)),
283+
],
284+
),
285+
);
286+
}
287+
}

lib/features/receive/ui/screens/receive_qr_screen.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
55
import 'package:bb_mobile/core/wallet/domain/entities/wallet_address.dart';
66
import 'package:bb_mobile/core/widgets/buttons/button.dart';
77
import 'package:bb_mobile/core/widgets/address_viewer.dart';
8+
import 'package:bb_mobile/core/widgets/bottom_sheet/disclosure_bottom_sheet.dart';
89
import 'package:bb_mobile/core/widgets/invoice_viewer.dart';
910
import 'package:bb_mobile/core/widgets/loading/loading_line_content.dart';
1011
import 'package:bb_mobile/core/widgets/snackbar_utils.dart';
@@ -318,6 +319,9 @@ class ReceiveLnInfoDetails extends StatelessWidget {
318319
);
319320
final note = context.select<ReceiveBloc, String>((bloc) => bloc.state.note);
320321
final swap = context.select((ReceiveBloc bloc) => bloc.state.getSwap);
322+
final showsLiquidDisclosure = context.select<ReceiveBloc, bool>(
323+
(bloc) => bloc.state.wallet?.isLiquid ?? false,
324+
);
321325

322326
return AnimatedContainer(
323327
duration: 300.ms,
@@ -426,6 +430,15 @@ class ReceiveLnInfoDetails extends StatelessWidget {
426430
),
427431
],
428432
const ReceiveLnFeesDetails(),
433+
if (showsLiquidDisclosure) ...[
434+
Container(color: context.appColors.surface, height: 1),
435+
DisclosureLink(
436+
label: context.loc.receiveLiquidRiskDisclosureLabel,
437+
semanticLabel: context.loc.liquidRiskDisclosureSemanticLabel,
438+
title: context.loc.liquidRiskDisclosureTitle,
439+
body: context.loc.liquidRiskDisclosureBody,
440+
),
441+
],
429442
],
430443
),
431444
);

lib/features/wallet/ui/screens/wallet_detail_screen.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:bb_mobile/core/themes/app_theme.dart';
22
import 'package:bb_mobile/core/widgets/bb_pullable_body.dart';
3+
import 'package:bb_mobile/core/widgets/bottom_sheet/disclosure_bottom_sheet.dart';
34
import 'package:bb_mobile/core/widgets/loading/loading_box_content.dart';
45
import 'package:bb_mobile/core/widgets/loading/loading_line_content.dart';
56
import 'package:bb_mobile/core/utils/amount_conversions.dart';
@@ -75,6 +76,19 @@ class WalletDetailScreen extends StatelessWidget {
7576
const SliverToBoxAdapter(child: Gap(8)),
7677
SliverToBoxAdapter(child: _CoinsEntryTile(wallet: wallet)),
7778
],
79+
if (wallet.isLiquid)
80+
SliverToBoxAdapter(
81+
child: Padding(
82+
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
83+
child: DisclosureLink(
84+
label: context.loc.walletLiquidRiskDisclosureLabel,
85+
semanticLabel:
86+
context.loc.liquidRiskDisclosureSemanticLabel,
87+
title: context.loc.liquidRiskDisclosureTitle,
88+
body: context.loc.liquidRiskDisclosureBody,
89+
),
90+
),
91+
),
7892
const SliverToBoxAdapter(child: Gap(16)),
7993
const WalletDetailTxsList(sliver: true),
8094
],

localization/app_en.arb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1559,6 +1559,10 @@
15591559
"@receiveLightningInvoice": {
15601560
"description": "Label for a Lightning Network invoice"
15611561
},
1562+
"receiveLiquidRiskDisclosureLabel": "Payments will be converted to Liquid L-BTC (not BTC). Learn more.",
1563+
"@receiveLiquidRiskDisclosureLabel": {
1564+
"description": "Small link label opening the Liquid Bitcoin risk disclosure on the Lightning receive screen"
1565+
},
15621566
"receiveAddress": "Receive Address",
15631567
"@receiveAddress": {
15641568
"description": "Label for generated address"
@@ -3449,6 +3453,22 @@
34493453
"@globalDefaultLiquidWalletLabel": {
34503454
"description": "Default label for Liquid/instant payments wallets when used as the default wallet"
34513455
},
3456+
"walletLiquidRiskDisclosureLabel": "These funds are Liquid L-BTC (not BTC). Learn more.",
3457+
"@walletLiquidRiskDisclosureLabel": {
3458+
"description": "Small link label opening the Liquid Bitcoin risk disclosure on Liquid wallet detail pages"
3459+
},
3460+
"liquidRiskDisclosureSemanticLabel": "Open Liquid Bitcoin risk disclosure",
3461+
"@liquidRiskDisclosureSemanticLabel": {
3462+
"description": "Accessibility label for the Liquid Bitcoin risk disclosure link"
3463+
},
3464+
"liquidRiskDisclosureTitle": "Instant Payments Wallet Liquid Bitcoin Risk Disclosure",
3465+
"@liquidRiskDisclosureTitle": {
3466+
"description": "Title for the Liquid Bitcoin risk disclosure bottom sheet"
3467+
},
3468+
"liquidRiskDisclosureBody": "The funds in the Instant Payment Wallet are not \"real\" Bitcoin. They are Liquid Network Bitcoin (L-BTC), a Bitcoin-backed token which exists on a separate ledger called a \"sidechain\".\n\nEach Liquid Bitcoin is backed 1:1 by real Bitcoin using a transparent peg mechanism maintained by a federation of 15 companies. The Bitcoin reserves held by the Liquid Federation are fully auditable, and anybody can verify that every Liquid Bitcoin is backed by a real Bitcoin.\n\nKeeping money on the Liquid Network is much safer than using a traditional custodial wallet or an exchange. However, holding your money as Liquid Bitcoin in the Instant Bitcoin Payments wallet is far less secure than holding it as real Bitcoin in the Secure Bitcoin Wallet.\n\nLiquid Network payments are faster, cheaper and more private than regular Bitcoin payments. You have full self-custody of the Liquid Bitcoin assets in the Instant Payments Wallet and you don't need any permission from anyone to send and receive payments.\n\nYou are fully and exclusively responsible for securing access to your wallet backup, without which nobody can restore access to your Liquid Bitcoin assets.\n\nThe real-world value of the L-BTC depends on your ability to redeem them for real Bitcoin, which in turn depends on swap providers that are members of the Liquid Network, which in turn depends on the Liquid Network federation never being compromised or shut down.\n\nIn other words, in the event that Liquid Network federation members become unable or unwilling to redeem your Liquid Bitcoin for real Bitcoin, the value of L-BTC tokens would become worthless.\n\n> Recommendation\n> Only keep small amounts in the Instant Payments Wallet. Keep your savings in the Secure Bitcoin Wallet or in an imported hardware wallet.\n\n## What is the Instant Payments Wallet?\n\nEvery time you receive a Lightning Network payment with the Instant Payment Wallet, the sender's funds are actually received by a 3rd party swap provider, converted to Liquid Bitcoin and sent to your wallet.\n\nEvery time you send a Lightning Network payment with this wallet, you are actually sending a Liquid Bitcoin payment to a 3rd party swap provider which converts them to real Bitcoin on the Lightning Network and sends a Lightning Network payment to the recipient.\n\nThese swaps are fully non-custodial and trustless, meaning that there is no way for the swap provider to steal your funds. Bull Bitcoin is not involved in making those swap transactions and does not require or store any personal information.\n\n## What should I do with this wallet?\n\nThe Instant Payments Wallet is designed for you to be able to receive Bitcoin payments (or buying Bitcoin) and send Bitcoin payments (or sell Bitcoin) on a day-to-day basis without paying Bitcoin network fees.\n\nIf you are a merchant or you are accumulating Bitcoin, use this wallet to receive payments below 0.01 BTC and, once you have accumulated over 0.01 BTC, transfer those Bitcoins to the Secure Bitcoin Wallet or any other self-custodial Bitcoin wallet.\n\nIf you want to spend your Bitcoin, transfer a small amount in the Instant Payment Wallet and use it to pay for your expenses. When you run out of funds in the Instant Payments Wallet, simply refill it.\n\n## What is auto-swap and how does it work?\n\nAuto-swap, also called Auto-Transfer, is a feature invented by Bull Bitcoin which automatically converts Liquid Network Bitcoin (L-BTC) to Bitcoin (BTC) once the L-BTC amount in your wallet reaches a defined threshold.\n\nAuto-swap is enabled by default:\n\n- **Maximum Instant Wallet balance:** 0.01 BTC\n- **Target balance after auto-swap:** 0.005 BTC\n- **Minimum transfer amount:** 0.005 BTC\n\nWhen your balance reaches 0.01 BTC, a transfer will be initiated as soon as you open the app and any funds in excess of 0.005 BTC will be converted from L-BTC to BTC.\n\n### Example\n\nIf you have 0.012 L-BTC and auto-swap is enabled, a transfer of 0.007 L-BTC will be initiated and your remaining L-BTC will be 0.005 L-BTC, and your BTC balance will be increased by 0.007 BTC.\n\n## How do I fund or withdraw L-BTC from the Instant Bitcoin Wallet?\n\nTo fund the wallet with \"real\" Bitcoin, click \"Receive\" and select \"Lightning Network\". To withdraw L-BTC, click \"Send\" and paste a Lightning invoice.\n\n## How does the backup work?\n\nThe Instant Bitcoin Wallet uses the same backup seed words as the Secure Bitcoin Wallet. You only need one backup for both wallets.\n\n## Should I use the Instant Payments wallet instead of a non-custodial Lightning Network Wallet?\n\nUsing a non-custodial Lightning Network wallet such as Phoenix Wallet will give you a higher degree of security. Because you will have to deal with Lightning Network channel management, it may be less convenient and harder to use.\n\nBecause Lightning channels require on-chain transactions to be opened and closed, and because you will likely need to pay a fee to Lightning service providers, it may in some cases be more expensive to use a fully non-custodial Lightning wallet.\n\nIf you are able and willing to use a fully non-custodial pure Lightning wallet, we highly recommend you try Phoenix Wallet.\n\n## What are the transaction fees?\n\nEach payment in and out of the Instant Payments Wallet implies two Liquid Network transactions. These are typically very small and should be around 0.00000050 BTC (50 sats).\n\nIf you are receiving or sending Lightning Network payments with the Secure Bitcoin Wallet, you will have to pay for two on-chain Bitcoin transactions every payment, which can be quite expensive.\n\nIn addition, the swap provider may also charge swap fees. At the moment, the swap provider (Boltz) charges the following fees:\n\n- **Receiving Lightning Network payments in the Instant Payment Wallet:** 0.25%\n- **Sending Lightning Network payments from the Instant Payment Wallet:** 0.1%\n- **Receiving Lightning Network payments in the Secure Bitcoin Wallet:** 0.5%\n- **Sending Lightning Network payments from the Secure Bitcoin Wallet:** 0.1%",
3469+
"@liquidRiskDisclosureBody": {
3470+
"description": "Long-form disclosure explaining Liquid Bitcoin and Instant Payments wallet risks. Preserve lightweight formatting markers: ## section headings, ### subsection headings, > callouts, - bullets, and **bold bullet labels**."
3471+
},
34523472
"walletTypeWatchOnly": "Watch-Only",
34533473
"@walletTypeWatchOnly": {
34543474
"description": "Wallet type label for watch-only wallets"

0 commit comments

Comments
 (0)