feat: let users view and export federation invite codes - #570
feat: let users view and export federation invite codes#570FrankChinedu wants to merge 5 commits into
Conversation
The seed phrase restores funds but records nothing about which federations hold them, and the Nostr backup of the invite codes is best effort by design (relays may prune). Until now the app gave the user no way to keep their own copy, so if that backup missed there was no fallback (fixes fedimint#501). Adds a Settings screen listing each joined federation with its invite code, individually copyable plus a "copy all" that labels each code with its federation so the export is meaningful pasted beside a written seed phrase. Also nudges toward it from the seed backup screen, which is where the user is actually writing a backup down. Backed by a new `get_federation_invite_codes` rather than the existing `get_all_invite_codes`: the latter returns a flat list with one code per guardian peer and no federation attached, which is right for the Nostr backup (redundancy raises the chance of a successful rejoin) but wrong for a list a human reads. The new one returns one code per federation, taking the first peer whose code resolves rather than assuming peer 0 exists. Invite codes carry no spending authority, so unlike the seed phrase the screen is deliberately not behind the PIN gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cae16cb2d2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| child: CopyableDetailRow( | ||
| label: entry.$1.federationName, | ||
| value: entry.$2, | ||
| abbreviate: true, |
There was a problem hiding this comment.
Show the full invite code for paper backups
When a user follows the screen's guidance to save these codes alongside a written seed phrase, abbreviate: true reduces every invite code to its first and last seven characters, with no control to reveal the omitted text. Copying supports a digital export, but the advertised view cannot be transcribed or otherwise inspected; render the complete wrapping value or provide an explicit full-code/QR view.
Useful? React with 👍 / 👎.
The screen tells the user to record these codes alongside a written seed phrase, but rendered each one abbreviated: getAbbreviatedText reduces anything over 14 characters to first7...last7, so a ~300 character invite code displayed as about 17, with no way to reveal the rest. Copying still worked, but the view the screen advertises could not be written down or checked against an earlier copy. Drops `abbreviate` so the value renders in full and wraps. The row already uses a monospace face, which is what makes reading a long code back character by character workable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 75c0f57f2f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| void _copyAll(List<(FederationSelector, String)> codes) { | ||
| Clipboard.setData(ClipboardData(text: _asExportText(codes))); | ||
| ToastService().show( | ||
| message: context.l10n.allInviteCodesCopied, |
There was a problem hiding this comment.
Await the clipboard write before reporting success
If the platform clipboard call rejects—for example because the channel is unavailable or browser clipboard access is denied—this ignored future still allows the success toast to appear, leaving the user with no exported backup despite being told all codes were copied. Make _copyAll async, await Clipboard.setData, and show an error toast when it fails.
AGENTS.md reference: AGENTS.md:L146-L148
Useful? React with 👍 / 👎.
| if let Some(invite_code) = client.invite_code(*peer).await { | ||
| invite_codes.push((selector, invite_code.to_string())); | ||
| break; |
There was a problem hiding this comment.
Preserve guardian fallback in exported backups
When the single guardian represented by this chosen code is unreachable at restore time, the saved backup cannot bootstrap the federation even if its other guardians and quorum are healthy. The existing Nostr backup deliberately retains every peer code for exactly this fallback, so the user-facing export should likewise retain all peer codes per federation (while grouping them under one label) rather than stopping after the first.
Useful? React with 👍 / 👎.
Two review findings on the invite code export. The exported code was built from a single guardian, so a backup made from it could not bootstrap the federation if that one guardian was unreachable at restore time, even with the rest of the federation healthy. Build the code with InviteCode::new_with_essential_num_guardians instead, which packs max_evil() + 1 guardians into one code — enough to always reach a working federation. That keeps one code per federation rather than listing every peer's, as the Nostr backup does. The redundancy lives inside the single code, so the list stays something a person can read and transcribe, which is the point of the screen. The "all invite codes copied" toast also fired without awaiting the clipboard write, so a rejected write — an unavailable channel, or a browser denying access — still told the user they had a backup. Await it and toast the failure instead, per the error handling convention in AGENTS.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5347feab9d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if !joined.contains(&key.id) { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
Include persisted federations when clients are absent
When recovery finishes but reopening the replacement client fails, retire_recovery_client leaves the federation's config persisted while removing its client until the next launch; this filter therefore silently omits that federation from the exported backup even though the stored config below contains everything needed to construct its invite code. Since leaving deletes the config before removing the client, iterating all remaining configs is sufficient and preserves the user's backup in this recovery-failure state.
Useful? React with 👍 / 👎.
| child: CopyableDetailRow( | ||
| label: entry.$1.federationName, | ||
| value: entry.$2, | ||
| ), |
There was a problem hiding this comment.
Handle failures when copying an individual code
Although _copyAll now awaits its clipboard write, each per-card copy action still delegates to CopyableDetailRow._copyToClipboard, which ignores the Clipboard.setData future and immediately displays its check icon. If clipboard access is rejected or the platform channel is unavailable, the user is therefore told this backup succeeded when nothing was copied; make this path await the write and surface a failure toast as well.
AGENTS.md reference: AGENTS.md:L146-L148
Useful? React with 👍 / 👎.
| let invite_code = | ||
| InviteCode::new_with_essential_num_guardians(&peer_urls, key.id).to_string(); |
There was a problem hiding this comment.
Preserve API secrets in exported invite codes
For a private federation whose original invite carries an API secret, rebuilding the export from only peer_urls and the federation ID drops that credential. The client-generated invite path retains and later passes invite.api_secret() when constructing the guardian API, so the displayed backup produced here cannot authenticate to such a federation during restore; construct the multi-guardian code while preserving the client's invite secret, or export the original authenticated peer codes.
Useful? React with 👍 / 👎.
Three more review findings on the invite code export. Rebuilding the code from peer URLs and the federation id alone dropped the api secret, so a private federation's exported backup could not authenticate on restore. Build with InviteCode::from_map, which takes the same multi-guardian set plus the secret. The secret is not in the persisted config, only on the client's own invite code, so it is read from there when a client is open; when none is, the federation is still exported without it rather than omitted. Listing was also filtered by the client map, which drops a federation whose config outlives its client — retire_recovery_client leaves exactly that gap when reopening the replacement client fails. Since leave_federation deletes the config before removing the client, every remaining config is a federation the user is still in, so iterate those instead. CopyableDetailRow ignored its Clipboard.setData future and showed the copied tick regardless, so a rejected write told the user their value was copied when nothing was. Await it and toast the failure instead. That tick is the only feedback the row gives, and it is used for seed-adjacent values across the app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b469fffd45
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| } | ||
| } | ||
|
|
||
| let invite_code = InviteCode::from_map(&peer_urls, key.id, api_secret).to_string(); |
There was a problem hiding this comment.
Do not export unauthenticated private-federation codes
When a private federation's client is absent—such as after retire_recovery_client removes it and reopening the replacement fails—api_secret remains None, yet this line still exports the code and the UI presents it as a usable backup; restoration will fail because guardians cannot authenticate it. Fresh evidence in the current patch is the explicit client-less fallback that knowingly constructs the code without the required secret. Persist the authenticated invite data before retiring the client, or return/surface an error instead of claiming that this federation was backed up.
AGENTS.md reference: AGENTS.md:L146-L148
Useful? React with 👍 / 👎.
| Widget build(BuildContext context) { | ||
| final theme = Theme.of(context); | ||
|
|
||
| return Scaffold( |
There was a problem hiding this comment.
Protect authenticated invite codes as bearer secrets
For private federations, the Rust path deliberately embeds an API secret that authenticates whoever holds the resulting invite code, so the assertion that every displayed code is non-secret is incorrect. Returning a bare Scaffold also bypasses both the sensitive-action PIN check and SecureScreen, whose repository documentation says bearer secrets must be protected from Android screenshots, screen recordings, and app-switcher thumbnails; gate this screen and wrap the rendered codes accordingly, or distinguish public codes from secret-bearing ones.
Useful? React with 👍 / 👎.
| let mut api_secret = None; | ||
| if let Some(client) = clients.get(&key.id) { | ||
| for peer in peer_urls.keys() { | ||
| if let Some(invite) = client.invite_code(*peer).await { |
There was a problem hiding this comment.
Bound guardian lookups while loading the backup
When the first queried guardian is unreachable or black-holed, this per-peer network request has no timeout, and the sequential loop cannot try another peer or return codes for any other federation. InviteCodesScreen._load awaits the entire function while showing only a progress indicator, so opening the backup screen can remain stuck indefinitely; apply a per-peer timeout and continue to the next endpoint, or bound the complete operation and surface the failure.
Useful? React with 👍 / 👎.
Exported codes now embed a private federation's api secret, which authenticates whoever holds it, so this screen can render a bearer credential. It was returning a bare Scaffold, leaving the codes free to reach a screenshot, a screen recording or the app-switcher thumbnail — and a backup screenshotted into a cloud-synced camera roll is exactly the leak worth closing. Wrap it in SecureScreen, as the seed phrase and ecash token screens already are. Left off the PIN gate deliberately. None of these codes can spend funds — that needs the seed phrase, which is gated separately — and this screen exists to encourage a backup the user does not currently make. Friction works against that, and the gate is a no-op anyway for anyone who has not set a PIN. The doc comment claiming every code is non-secret is corrected: that is true of public federations only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The seed phrase restores funds but records nothing about which federations hold them, and the Nostr backup of the invite codes is best effort by design (relays may prune). Until now the app gave the user no way to keep their own copy, so if that backup missed there was no fallback (fixes #501).
Adds a Settings screen listing each joined federation with its invite code, individually copyable plus a "copy all" that labels each code with its federation so the export is meaningful pasted beside a written seed phrase. Also nudges toward it from the seed backup screen, which is where the user is actually writing a backup down.
Backed by a new
get_federation_invite_codesrather than the existingget_all_invite_codes: the latter returns a flat list with one code per guardian peer and no federation attached, which is right for the Nostr backup (redundancy raises the chance of a successful rejoin) but wrong for a list a human reads. The new one returns one code per federation, taking the first peer whose code resolves rather than assuming peer 0 exists.Invite codes carry no spending authority, so unlike the seed phrase the screen is deliberately not behind the PIN gate.