Skip to content

Commit a716a0b

Browse files
hhanh00claude
andcommitted
feat: ZSA holdings, issuance, send support + fix split-spend signing
- Add ZsaHolding type and list_zsa_holdings API (Rust + Flutter bindings) - Add ZSA holdings page with per-asset balance view - Add IssueAssetPage for new token issuance - Add currency dropdown to send page (ZEC + ZSA selection) - Support sending ZSA tokens with correct asset_base/asset_name - Fix missing split-spend signing in sign_transaction (orchard_split_spend_indices) - Share get_zsa_holdings between Flutter API and GraphQL assets query - Refactor Recipient to include assetBase field Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a275206 commit a716a0b

26 files changed

Lines changed: 1599 additions & 241 deletions

lib/pages/account.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ class AccountViewPageState extends ConsumerState<AccountViewPage> with SingleTic
8989
onUpdateAllTxPrices();
9090
case "charts":
9191
GoRouter.of(context).push("/chart");
92+
case "zsa":
93+
GoRouter.of(context).push("/zsa");
9294
default:
9395
onExport(int.parse(result));
9496
}
@@ -107,6 +109,10 @@ class AccountViewPageState extends ConsumerState<AccountViewPage> with SingleTic
107109
value: "charts",
108110
child: Text("Charts"),
109111
),
112+
const PopupMenuItem<String>(
113+
value: "zsa",
114+
child: Text("ZSA Holdings"),
115+
),
110116
],
111117
),
112118
],

lib/pages/send.dart

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import 'dart:typed_data';
2+
13
import 'package:collection/collection.dart';
4+
import 'package:convert/convert.dart';
25
import 'package:flutter/material.dart';
36
import 'package:flutter_form_builder/flutter_form_builder.dart';
47
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -15,6 +18,7 @@ import 'package:zkool/src/rust/api/account.dart';
1518
import 'package:zkool/src/rust/api/key.dart';
1619
import 'package:zkool/src/rust/api/pay.dart';
1720
import 'package:zkool/src/rust/api/sync.dart';
21+
import 'package:zkool/src/rust/api/zsa.dart';
1822
import 'package:zkool/src/rust/pay.dart';
1923
import 'package:zkool/store.dart';
2024
import 'package:zkool/utils.dart';
@@ -24,6 +28,9 @@ import 'package:zkool/widgets/input_amount.dart';
2428
import 'package:zkool/widgets/pool_select.dart';
2529
import 'package:zkool/widgets/scanner.dart';
2630

31+
/// The native ZEC asset base (32 zero bytes).
32+
final zecBase = Uint8List(32);
33+
2734
final addressID = GlobalKey();
2835
final scanID = GlobalKey();
2936
final amountID = GlobalKey();
@@ -54,6 +61,9 @@ class SendPageState extends ConsumerState<SendPage> {
5461
String? address;
5562
String? amount;
5663
String? memo;
64+
List<ZsaHolding> zsas = [];
65+
Uint8List selectedAssetBase = zecBase;
66+
String? selectedAssetName;
5767

5868
void tutorial() async {
5969
tutorialHelper(context, "tutSend0", [addressID, scanID, amountID, openTxID, addTxID, sendID2]);
@@ -68,11 +78,13 @@ class SendPageState extends ConsumerState<SendPage> {
6878
final data = (await ref.read(accountProvider(selectedAccount.id).future));
6979
final bal = await balance(c: c);
7080
final addrs = await getAddresses(uaPools: data.pool, c: c);
81+
final zsaHoldings = await listZsaHoldings(c: c);
7182

7283
setState(() {
7384
account = data;
7485
pbalance = bal;
7586
addresses = addrs;
87+
zsas = zsaHoldings;
7688
});
7789
});
7890
}
@@ -191,12 +203,34 @@ class SendPageState extends ConsumerState<SendPage> {
191203
),
192204
],
193205
),
206+
FormBuilderDropdown<Uint8List>(
207+
name: "asset",
208+
decoration: const InputDecoration(labelText: "Currency"),
209+
initialValue: zecBase,
210+
items: [
211+
DropdownMenuItem(value: zecBase, child: const Text("ZEC")),
212+
...zsas.map((z) => DropdownMenuItem(
213+
value: z.assetBase,
214+
child: Text(z.assetName.isNotEmpty ? z.assetName : hex.encode(z.assetDescHash.sublist(0, 8))),
215+
)),
216+
],
217+
onChanged: (v) {
218+
setState(() {
219+
selectedAssetBase = v ?? zecBase;
220+
selectedAssetName = zsas.firstWhereOrNull((z) => z.assetBase == v)?.assetName;
221+
});
222+
},
223+
),
194224
InputAmount(
195225
key: amountKey,
196226
name: "amount",
197227
initialValue: amount,
198228
onChanged: (v) => setState(() => amount = v),
199-
onMax: onMax,
229+
onMax: selectedAssetBase.every((b) => b == 0) ? onMax : null,
230+
showFx: selectedAssetBase.every((b) => b == 0),
231+
label: selectedAssetName != null
232+
? "Amount in $selectedAssetName"
233+
: "Amount in ZEC",
200234
),
201235
Visibility(
202236
visible: supportsMemo,
@@ -273,6 +307,7 @@ class SendPageState extends ConsumerState<SendPage> {
273307
Recipient(
274308
address: addresses?.oaddr ?? addresses?.saddr ?? "", // Shield to Orchard or Sapling address
275309
amount: pbalance?.field0[0] ?? BigInt.zero,
310+
assetBase: zecBase,
276311
),
277312
],
278313
options: options,
@@ -293,7 +328,7 @@ class SendPageState extends ConsumerState<SendPage> {
293328
smartTransparent: false,
294329
);
295330
final pczt = await prepare(
296-
recipients: [Recipient(address: addresses?.taddr ?? "", amount: (pbalance?.field0[1] ?? BigInt.zero) + (pbalance?.field0[2] ?? BigInt.zero))],
331+
recipients: [Recipient(address: addresses?.taddr ?? "", amount: (pbalance?.field0[1] ?? BigInt.zero) + (pbalance?.field0[2] ?? BigInt.zero), assetBase: zecBase)],
297332
options: options,
298333
c: c,
299334
);
@@ -398,7 +433,15 @@ class SendPageState extends ConsumerState<SendPage> {
398433
final price = (fxStr != null) ? stringToDecimal(fxStr).toDecimal().toDouble() : null;
399434
logger.i("Send $amount to $address");
400435

401-
final recipient = Recipient(address: address, amount: stringToZat(amount), userMemo: memo, price: price);
436+
final isZec = selectedAssetBase.every((b) => b == 0);
437+
final recipient = Recipient(
438+
address: address,
439+
amount: isZec ? stringToZat(amount) : BigInt.parse(amount),
440+
userMemo: memo,
441+
price: price,
442+
assetBase: selectedAssetBase,
443+
assetName: selectedAssetName,
444+
);
402445
return recipient;
403446
}
404447
return null;

lib/pages/zsa.dart

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import 'package:convert/convert.dart';
2+
import 'package:flutter/material.dart';
3+
import 'package:flutter_form_builder/flutter_form_builder.dart';
4+
import 'package:flutter_riverpod/flutter_riverpod.dart';
5+
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart';
6+
import 'package:form_builder_validators/form_builder_validators.dart';
7+
import 'package:gap/gap.dart';
8+
import 'package:go_router/go_router.dart';
9+
import 'package:zkool/src/rust/api/issuance.dart';
10+
import 'package:zkool/src/rust/api/pay.dart';
11+
import 'package:zkool/store.dart';
12+
import 'package:zkool/utils.dart';
13+
import 'package:zkool/widgets/error_display.dart';
14+
15+
class ZsaHoldingsPage extends ConsumerStatefulWidget {
16+
const ZsaHoldingsPage({super.key});
17+
18+
@override
19+
ConsumerState<ZsaHoldingsPage> createState() => _ZsaHoldingsPageState();
20+
}
21+
22+
class _ZsaHoldingsPageState extends ConsumerState<ZsaHoldingsPage> {
23+
@override
24+
Widget build(BuildContext context) {
25+
final tt = Theme.of(context).textTheme;
26+
27+
final accountData = ref.watch(getCurrentAccountProvider);
28+
29+
return accountData.when(
30+
loading: () => blank(context),
31+
error: (error, stack) => showError(error),
32+
data: (data) {
33+
final zsas = data?.zsas ?? [];
34+
35+
return Scaffold(
36+
appBar: AppBar(
37+
title: const Text("ZSA Holdings"),
38+
leading: IconButton(
39+
icon: const Icon(Icons.arrow_back),
40+
onPressed: () => GoRouter.of(context).pop(),
41+
),
42+
actions: [
43+
IconButton(
44+
tooltip: "Issue new token",
45+
icon: const Icon(Icons.add),
46+
onPressed: () => GoRouter.of(context).push("/zsa/issue"),
47+
),
48+
],
49+
),
50+
body: CustomScrollView(
51+
slivers: [
52+
if (zsas.isEmpty)
53+
SliverFillRemaining(
54+
child: Center(
55+
child: Text("Any ZSA tokens you receive will appear here.", style: tt.bodyMedium),
56+
),
57+
)
58+
else
59+
SliverFixedExtentList.builder(
60+
itemCount: zsas.length,
61+
itemExtent: 64,
62+
itemBuilder: (context, index) {
63+
final h = zsas[index];
64+
65+
final displayName = h.assetName.isNotEmpty
66+
? h.assetName
67+
: hex.encode(h.assetDescHash.sublist(0, 8));
68+
69+
return Column(
70+
children: [
71+
Expanded(
72+
child: ListTile(
73+
leading: CircleAvatar(
74+
backgroundColor: Colors.blue,
75+
child: Text(
76+
initials(displayName),
77+
style: tt.titleMedium?.copyWith(color: Colors.white),
78+
),
79+
),
80+
title: Text(displayName),
81+
subtitle: Text(hex.encode(h.assetDescHash.sublist(0, 8))),
82+
trailing: Text(h.balance.toString(), style: tt.titleMedium),
83+
),
84+
),
85+
const Divider(height: 1, thickness: 1, indent: 16, endIndent: 16),
86+
],
87+
);
88+
},
89+
),
90+
],
91+
),
92+
);
93+
},
94+
);
95+
}
96+
}
97+
98+
class IssueAssetPage extends ConsumerStatefulWidget {
99+
const IssueAssetPage({super.key});
100+
101+
@override
102+
ConsumerState<IssueAssetPage> createState() => _IssueAssetPageState();
103+
}
104+
105+
class _IssueAssetPageState extends ConsumerState<IssueAssetPage> {
106+
static final _maxSupply = BigInt.from(21000000) * BigInt.from(100000000);
107+
final _formKey = GlobalKey<FormBuilderState>();
108+
109+
@override
110+
Widget build(BuildContext context) {
111+
return Scaffold(
112+
appBar: AppBar(
113+
title: const Text("Issue New Token"),
114+
actions: [
115+
IconButton(
116+
tooltip: "Issue",
117+
icon: const Icon(Icons.check),
118+
onPressed: _issue,
119+
),
120+
],
121+
),
122+
body: Padding(
123+
padding: const EdgeInsets.all(16),
124+
child: FormBuilder(
125+
key: _formKey,
126+
initialValue: const {
127+
"first_issuance": false,
128+
"finalize": false,
129+
},
130+
child: Column(
131+
crossAxisAlignment: CrossAxisAlignment.stretch,
132+
children: [
133+
// TODO: to support adding supply to an existing asset, we need
134+
// an optional asset_desc_hash field. The asset name alone doesn't
135+
// identify the asset — the desc_hash is the canonical identifier.
136+
FormBuilderTextField(
137+
name: "asset_name",
138+
decoration: const InputDecoration(labelText: "Asset Name"),
139+
validator: FormBuilderValidators.required(),
140+
),
141+
const Gap(16),
142+
FormBuilderTextField(
143+
name: "amount",
144+
decoration: const InputDecoration(labelText: "Amount"),
145+
keyboardType: TextInputType.number,
146+
validator: FormBuilderValidators.compose([
147+
FormBuilderValidators.required(),
148+
FormBuilderValidators.integer(),
149+
(v) {
150+
if (v == null) return null;
151+
final n = BigInt.tryParse(v);
152+
if (n == null) return "Invalid number";
153+
if (n <= BigInt.zero) return "Must be greater than 0";
154+
if (n > _maxSupply) return "Exceeds max supply of 21 million";
155+
return null;
156+
},
157+
]),
158+
),
159+
const Gap(16),
160+
FormBuilderSwitch(
161+
name: "first_issuance",
162+
title: const Text("First Issuance"),
163+
subtitle: const Text("Include a zero-value reference note (ZIP-227)"),
164+
),
165+
FormBuilderSwitch(
166+
name: "finalize",
167+
title: const Text("Finalize"),
168+
subtitle: const Text("Prevent any future issuance of this asset"),
169+
),
170+
],
171+
),
172+
),
173+
),
174+
);
175+
}
176+
177+
Future<void> _issue() async {
178+
final form = _formKey.currentState!;
179+
if (!form.saveAndValidate()) return;
180+
181+
final assetName = form.value["asset_name"] as String;
182+
final amount = form.value["amount"] as String;
183+
final firstIssuance = form.value["first_issuance"] as bool;
184+
final finalize = form.value["finalize"] as bool;
185+
186+
final confirmed = await confirmDialog(
187+
context,
188+
title: "Issue $assetName",
189+
message: "Issue $amount units of $assetName?${finalize ? ' This will finalize the asset.' : ''}",
190+
);
191+
if (!confirmed) return;
192+
193+
try {
194+
final txBytes = await issueAsset(
195+
assetName: assetName,
196+
amount: BigInt.parse(amount),
197+
firstIssuance: firstIssuance,
198+
finalize: finalize,
199+
idAccount: coinContext.coin.account,
200+
c: coinContext.coin,
201+
);
202+
final height = ref.read(currentHeightProvider) ?? 1;
203+
final txid = await broadcastTransaction(
204+
height: height,
205+
txBytes: txBytes,
206+
c: coinContext.coin,
207+
);
208+
if (!mounted) return;
209+
ScaffoldMessenger.of(context).showSnackBar(
210+
SnackBar(content: Text("Transaction broadcast: $txid")),
211+
);
212+
GoRouter.of(context).pop();
213+
} on AnyhowException catch (e) {
214+
if (mounted) await showException(context, e.message);
215+
}
216+
}
217+
}

lib/router.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import 'package:zkool/pages/send.dart';
2020
import 'package:zkool/pages/splash.dart';
2121
import 'package:zkool/pages/tx.dart';
2222
import 'package:zkool/pages/tx_view.dart';
23+
import 'package:zkool/pages/zsa.dart';
2324
import 'package:zkool/settings.dart';
2425
import 'package:zkool/src/rust/api/account.dart';
2526
import 'package:zkool/src/rust/api/pay.dart';
@@ -109,6 +110,8 @@ GoRouter router(bool disclaimerAccepted, bool recoveryMode) => GoRouter(
109110
GoRoute(path: '/disclaimer', builder: (context, state) => DisclaimerPage()),
110111
GoRoute(path: '/chart', builder: (context, state) => ChartPage()),
111112
GoRoute(path: '/show_animated_qr', builder: (context, state) => ShowAnimatedQRPage(state.extra as List<Uint8List>)),
113+
GoRoute(path: '/zsa', builder: (context, state) => const ZsaHoldingsPage()),
114+
GoRoute(path: '/zsa/issue', builder: (context, state) => const IssueAssetPage()),
112115
GoRoute(path: '/scan_animated_qr', builder: (context, state) => ScanAnimatedQRPage()),
113116
],
114117
);

0 commit comments

Comments
 (0)