Skip to content

Unify the Epic Cash and MWC wallets and slatepack UI - #1507

Open
sneurlax wants to merge 37 commits into
stagingfrom
feat/slatepack-interface
Open

sneurlax wants to merge 37 commits into
stagingfrom
feat/slatepack-interface

Conversation

@sneurlax

@sneurlax sneurlax commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Epic Cash and MWC now share one wallet base and one slatepack UI.

Refactor:

  • MimblewimbleWallet is a shared base for EpicCashWallet and MimblewimblecoinWallet, with a shared native transaction model (MimblewimbleTransaction) and shared slatepack and relay server models.
  • One slatepack UI serves both coins. Screens go through a new SlatepackInterface, so a coin declares its methods and capabilities instead of the screens checking the coin type.
  • Stored per-coin wallet info keeps its keys and types, and the relay Hive adapters keep their type IDs, so no data migration is needed.

Fixes:

  • MWC estimates a creation height from its own genesis time, instead of Epic's.
  • A slatepack too long for a QR code shows a message with copy, share and save, instead of a broken QR.
  • Local notes are kept when creating a slatepack.
  • Slatepack input that arrives after the screen closes is ignored.
  • An Epicbox send to an unreachable relay shows "Unable to reach epicbox server". Before, the send dialog stayed up forever, because the pre-send check awaited a websocket sink.close() that never completes after a failed connection.
  • The on-chain note of sent Epic and MWC transactions shows the sender's message. Before, it showed "null".
  • The Epicbox insecure flag is saved under the key the plugin reads (epicbox_protocol_unsecure), and both older spellings still load.
  • The help text on the desktop Slatepack screen is readable.
  • A typed http:// or https:// send address stays in the field. Before, typing http: was parsed as a payment URI with an empty address and cleared the field, so only pasting worked.
  • Typed Epic Cash and MWC send addresses stay intact. The send views added a listener inside build() that rewrote the field on every change, so on Android only the first typed character survived.
  • MWC fee estimation is skipped when there is nothing to spend. Before, opening Send in an empty MWC wallet could abort the app inside libmwc.
  • A scanned Epic Cash or MWC address, or one from a payment URI, has its relay prefix stripped like a typed one.
  • The block explorer settings list only coins that have a default block explorer. Opening the entry for Epic Cash or MWC threw.
  • A slatepack created for another amount than the confirmed one is cancelled and refused. A different fee is shown as a warning. Failures are titled "Could not create the slate" or "slatepack", where they said "Broadcast transaction failed".
  • Finalize is offered only for a send this wallet started.
  • A slatepack QR code is drawn whenever the data fits in one (2953 bytes). The earlier 1000 character cap took the QR code away from longer Epic Cash slates and MWC slatepacks.
  • Slatepack text is normalised before import, so text around the armor does not make it fail.
  • A second press of Continue no longer starts the import twice.
  • The response to a received slatepack is shown even when the screen that started the import has closed, and the loading overlay is taken down in that case too.
  • Share on the slatepack dialog works on an iPad.
  • The sending dialog is closed by its own route, so a route opened above it is not popped in its place.

Testing:

  • Mobile: run on an Android 14 emulator, a physical arm64 Android phone and the iOS Simulator: Epic restore and new wallet, slate file receive and Save, HTTP send, the share sheet and the file picker.
  • Unit and widget tests: full flutter test, 965 passed, 8 skipped. One Firo cache test from staging (test/db/firo_cache_clear_test.dart) timed out in that run and passes on its own. New tests cover slatepack routing, slate inspection, the QR limit, text normalisation, the import and finalize flows, note persistence, the Epicbox probe, on-chain notes, the Epicbox config model and the send address fields.
  • Checked against private Epic Cash and MWC test chains, with a real node, a relay and CLI peer wallets:
    • Epicbox send and receive.
    • A slate send, receive and finalize.
    • MWC slatepack send and receive.
    • Cancel.
  • Migration: wallets created, funded and given notes on staging keep their balances, full history (including a cancelled tx) and local notes, and sync on this branch.
  • Not covered: MWCMQS (no local relay) and mainnet.

Known issue, fixed separately:

The slatepack result types were identical apart from an Epic prefix, so
there is now one set. The epicbox/mwcmqs server and config models keep
their own classes, Hive type ids and JSON keys but inherit the common
fields from RelayServerModel and RelayConfigModel.
EpicTransaction/MwcTransaction and their message types had the same
fields. Both native interfaces now return MimblewimbleTransaction; the
templates in tool/wl_templates construct it, so regenerate lib/wl_gen
after pulling this.
Both wallets carried the same sync percent, scan loop, refresh
sequence, balance parsing, slate analysis and wallet info bookkeeping,
differing only in how they talk to their native library. The shared
parts now live in an abstract MimblewimbleWallet with hooks for the
native calls, and the slatepack/relay API (createSlatepack,
receiveSlatepack, finalizeSlatepack, getRelayConfig, ...) is declared
there so UI can be written against one type.

ExtraEpiccashWalletInfo and ExtraMimblewimblecoinWalletInfo had the
same JSON shape and are now one class; each wallet still stores it
under its own WalletInfoKeys entry.
The finalize view, entry/import/response dialogs, send-slatepack dialog
and method toggle existed twice, differing only in the wallet type they
cast to and in whether the copy said "slate" or "slatepack". They now
take a MimblewimbleWallet and pick their wording from SlatepackTerms.
The forked _createSlatepack/_importSlatepack/receive handlers in the
send, receive and confirm views collapse the same way.

EpicFinalizeView stays as a thin wrapper so its route name keeps
working. The transaction method enums and their providers become one
enum keyed by coin, keeping Epic's default of relay (epicbox) and MWC's
default of slatepack.
The MWC wallet copied Epic Cash's first-block timestamp (1565370278)
under the name mimblewimblecoinFirstBlock. MWC mainnet started on
2019-11-11 (1573462801, the value restore_wallet_view already uses), so
the estimate for a freshly created wallet landed about 130k blocks above
the actual tip: the initial scan and any later rescan from that height
never ran. Wallets restored from a seed were unaffected.

Staging already fixed this in 0e56467 with 1573462800. Rebased onto
it, this only aligns the value with restore_wallet_view.
qr 3 only notices oversized data while painting, after qr_flutter's own
validation has passed, so a slatepack too long for a level L code threw
during build. QR now checks the data first and shows an errorStateBuilder,
defaulting to a short message.
SlatepackInterface is the coin-neutral contract the slatepack screens
use: supported methods, on-chain note and invoice capabilities, terms,
restore progress, inspectSlatepack, localTxForSlate, create/receive/
finalize, cancel, and unsupported defaults for pending slatepacks,
repost, invoices and payment proofs. MimblewimbleWallet mixes it in;
inspectSlatepack replaces fullDecodeSlatepack and analyzeSlatepack.
SlatepackResult gains state, slateId, amount and fee, FinalizeResult
gains posted and kernelExcess, and receive returns a SlatepackResult.

UI changes:
- Slatepack sends go through prepareSend and the confirm screen on
  mobile and desktop, so the fee is shown. Epic and MWC prepareSend tag
  otherData with the method; the confirm screen creates the slatepack.
- SlatepackImportFlow inspects any pasted slatepack and dispatches on
  its state: receive S1, finalize S2, placeholders for invoices, messages
  for finalized and unknown slates. Finalize summaries use the local tx.
- One SlatepackDialog for S1/S2/I1/I2 with copy, share, save to
  <slateId>.<state>.slatepack, the encrypted flag and a QR code only up
  to 1000 characters, tap to enlarge.
- SlatepackInputField: paste, scan (QrCodeScannerDialog on desktop) and
  open file with a 256 KiB cap, used wherever a slatepack is entered.
- The Finalize nav item and desktop tab become "Slatepack" and open the
  import flow. Desktop receive always shows the address and QR.
- The method toggle is per wallet instead of per coin.
- Casts to MimblewimbleWallet and coin gates in slatepack UI use the
  interface. Relay settings stay coin specific.

Dead code removed: Epic's isSlatepack, MWC's slatepack type detection
helpers and its slatepack-as-address branch in prepareSend.
The pre-send relay check awaited sink.close() in a finally block. After a
failed connection that future never completes, so a send to an
unreachable or misconfigured relay left the sending dialog up forever
instead of reporting that the relay could not be reached. Move the check
into probeEpicboxServer, close the channel without waiting, and bound the
connect with a timeout.
Both plugins turn a missing slate message into the string "null", and on
a send the first participant entry belongs to the recipient. Taking
messages.first therefore showed "null" as the on-chain note of every sent
transaction. Use the first participant message that has text.
EpicboxConfig in the plugin reads epicbox_protocol_unsecure, but the
model wrote epicbox_protocol_insecure, so the flag never reached it.
Write the plugin's key and keep reading the two older spellings. Also
make copyWith on the Epicbox and MWCMQS config models apply their
arguments.
desktopTextExtraSmall uses the disabled button text color, which is
nearly white on the desktop card, so the explanation above the input was
almost invisible. Use the subtitle color like the other slatepack hints.
@sneurlax

Copy link
Copy Markdown
Member Author

See staging...refactor/epic-mwc-unify-rebased for an easier-to-review core of this functionality

@sneurlax sneurlax changed the title Unified Slatepack interface for MimbleWimble coins Unify the Epic Cash and MWC wallets and slatepack UI Nov 11, 2026
@sneurlax
sneurlax marked this pull request as ready for review November 11, 2026 02:29
@sneurlax

Copy link
Copy Markdown
Member Author

See that last note:

Known issue, fixed separately:

We will want to update this after those merge

The send field parses its text as a payment URI as soon as it contains a
colon. "http:" parsed as a URI with an empty address, so typing an Epic
Cash HTTP listener address cleared the field at the colon and left
"//host:port", which failed validation. Only pasting the whole URL
worked. Treat http and https URLs as addresses, not payment URIs.
The send views added a listener to the address controller inside build()
for Epic Cash (and, on desktop, MWC) that reassigned the controller text
on every change. Listeners piled up with each rebuild, and resetting the
text dropped the selection, so on Android only the first character of a
typed address survived; pasting still worked. Strip the relay address
prefixes in the field's onChanged instead, and only rewrite the field
when that changes the text.
Opening Send on an empty MWC wallet asks for a fee estimate. libmwc treats
amount == available as send all, retries with the amount minus the
required fee and aborts the app on the resulting negative amount (seen on
the iOS Simulator as a SIGABRT in mwc_rust_get_tx_fees). There is no fee to
estimate without a spendable balance or an amount, so return 0 without
calling the native library. The real send still reports insufficient
funds.
The address field's onChanged strips http(s)://, mailto: and the trailing
slash from an Epicbox or MWCMQS address, but onChanged only runs for typed
input. Since the build-time controller listener was removed, an address set
from a scanned QR code or from a payment URI kept its prefixes. Run the
scanned text and the URI address through the same stripping before the
address and the field are set, in the mobile send view for Epic Cash and
in the desktop send view for Epic Cash and MWC.
Epic Cash and MWC have no default block explorer, and asking for one
throws. The block explorer settings listed every coin, so opening the
entry for either of them failed while the edit screen was being built, on
mobile and in the desktop dialog. Only list coins that have a default
block explorer.
Creating a slatepack did not compare the result with what the user had
just confirmed, reported failures as "Broadcast transaction failed"
although nothing is broadcast, and dropped the result without a trace
when the confirmation view had closed in the meantime.

A slatepack created for another amount is now cancelled and refused
with an error naming both amounts. A different fee is logged and shown
as a warning only, because the confirmed fee is an estimate and the
wallet may select other inputs when it builds the slate. Failures are
titled "Could not create the slate" or "slatepack". The sending dialog
is closed through a navigator looked up before the send, so it no
longer stays up when the view is gone, and that case is logged.
An imported response slatepack was offered for finalizing even when the
wallet had no transaction for it, with only a note in the summary, and
a transaction the wallet had received counted as a match.

The lookup now reports the direction of the local transaction, prefers
the send when a wallet holds both sides of a slate, and refreshes the
stored history once before giving up, since a send made moments ago may
not be in it yet. Without a send of its own the import flow explains
that only the wallet that started the transaction can finalize it and
no longer shows the Finalize button.
The slatepack dialog refused to draw a QR code for anything over 1000
characters. Epic Cash slates and MWC slatepacks can be longer than that
and were drawn at any length before the shared dialog replaced the
per-coin ones, so the longer ones lost their QR code.

The limit is now what a QR code can hold at the error correction level
the widget draws at, 2953 bytes, counted in bytes instead of
characters. Longer data still gets the note to copy, share or save it.
The import flow passed whatever was pasted, scanned or opened straight
to the wallet, so a slatepack with a greeting, a quote marker or other
text around it was rejected.

The flow now trims the text and, when it holds an armored slatepack,
keeps only the part from the begin marker to the end marker. Both the
BEGINSLATEPACK and BEGINSLATE_BIN frames are recognised, with or
without the final period. JSON slates and anything without a frame are
only trimmed. Inspecting, receiving and finalizing all use that text.
On the slatepack screen a second press of Continue could start the
import again while the first was still under way. On a phone the screen
closes the keyboard and waits briefly before it starts, which leaves
room for that, and the slatepack would then be processed twice.

The screen now tracks a running import, ignores further presses and
disables the button until the import has returned.
Receiving a slatepack takes a while. If the screen that started the
import was gone by the time the wallet had signed it, the response was
dropped without a word, even though the wallet had already recorded
the transaction and the sender still needs that response.

The import flow now keeps hold of the root navigator before it starts
and shows the response from there when its own screen has closed. It
logs the slate id in that case, and also when a finalize completes
after the screen closed. The response text itself is not logged.
The slatepack dialog shared its text without a position for the share
sheet. An iPad needs one to place the popover and refuses to open the
sheet without it, so Share did nothing there apart from logging an
error.

The dialog now passes the Share button's rect on screen. Other devices
ignore it.
The sending dialog was closed by popping the root navigator. If another
route had opened above the dialog by the time the send returned, that
route was popped and the dialog stayed up.

The dialog's route is now kept and removed directly, so nothing else is
closed. A failed HTTP address also closes the dialog when the view is
already gone.
The import flow showed its loading overlay through showLoading, which
only removes the overlay while the calling screen is still mounted. If
that screen closed while the wallet was working, the overlay stayed
over the whole app, under the response that is now shown in that case.

The flow keeps its own overlay route on the root navigator and removes
it when the wallet answers. An error is shown from the root navigator
too when the screen is gone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant