Skip to content

All account types#529

Merged
n13 merged 21 commits into
mainfrom
add_encrypted_accounts_keystone_accounts
Jun 22, 2026
Merged

All account types#529
n13 merged 21 commits into
mainfrom
add_encrypted_accounts_keystone_accounts

Conversation

@n13

@n13 n13 commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Refactoring all account types and settings.

Included

  • Multiple software wallets — create/import additional wallets alongside the original.
  • Multisig wallets — create, discover (find multisigs where you're a signer), and propose/approve/cancel/execute. (behind enableMultisig remote flag)
  • Keystone hardware wallets (with enableKeystoneHardwareWallet flag)
    • Scan QR code to add a Keystone account
    • Sign transactions with Keystone
  • Encrypted (wormhole) accounts — a derived encrypted account per software wallet. (behind enableEncryptedAccount remote flag)
  • Disconnect accounts & wallets
    • Disconnect individual accounts (software / keystone / multisig)
    • Removing the last account of an additional wallet deletes the entire wallet and its recovery phrase from the device (two-step confirmation)
    • The original wallet (index 0) is protected — extra accounts can be removed, but not the last one (log out instead)
  • Deterministic account indices — new accounts fill the lowest free derivation index, so removing then re-adding accounts always reproduces the same addresses in the same order (unit-tested).
  • Accounts list grouped by wallet + account type; add-account menu reorganized into primary actions + an "Advanced" submenu.

Notes

  • New account types are gated behind remote-config flags for staged rollout (default off).
  • Merged latest main: BIP-44 gap-limit discovery, global toast provider refactor, silent refresh on notifications, multisig remote flag.
  • Added mobile-app/run_on_device.sh to build/install/run on iOS 26 devices from the command line (Flutter's Xcode automation is currently broken on iOS 26).

(Encrypted accounts were previously deferred in this description but are in fact included here, behind a flag.)

@n13 n13 changed the title Add encrypted accounts keystone accounts All account types Jun 22, 2026
…s_keystone_accounts

# Conflicts:
#	mobile-app/lib/providers/route_intent_providers.dart
#	mobile-app/lib/services/multisig_submission_service.dart
#	mobile-app/lib/v2/screens/accounts/add_account_menu_screen.dart
#	mobile-app/lib/v2/screens/accounts/create_account_screen.dart
#	quantus_sdk/lib/src/models/remote_config_model.dart
@n13 n13 marked this pull request as ready for review June 22, 2026 08:09
@n13

n13 commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator Author

Review — All account types

Reviewed the full diff against main (37 files, +2552/-322). Overall this is a well-structured PR: the grouping logic is pure and unit-tested, destructive flows have solid confirmation gating, error paths log instead of failing silently, and the deterministic index logic is well covered. A few issues below, ordered by severity.

Bugs

1. nextNonHardwareWalletIndex can collide with an existing hardware wallet's index

// account_utils.dart
int nextNonHardwareWalletIndex(List<Account> accounts) {
  final used = getNonHardwareWalletIndices(accounts).toSet(); // non-hardware only
  ...
}

walletIndex is a single shared namespace (grouping, mnemonic storage, and getNextFreeAccountIndex are all keyed by it), but this only treats non-hardware indices as used. Hardware imports use nextWalletIndex (union of all indices).

Repro: primary software wallet at index 0 → add a Keystone wallet (nextWalletIndex → 1) → import a software recovery phrase (nextNonHardwareWalletIndex sees only {0} → returns 1, colliding with the Keystone wallet). The imported software accounts and the Keystone account then share walletIndex 1, which:

  • breaks groupAccounts (a mixed group is no longer detected as keystone),
  • makes getNextFreeAccountIndex(1) count the keystone slot,
  • causes ensureEncryptedAccountsForSoftwareWallets to attach an encrypted account to the mixed group,
  • means removeWallet(1) would delete both the imported wallet and the keystone account.

A new wallet index must be unique across all wallet types. Suggest using nextWalletIndex for the software-import path too and dropping nextNonHardwareWalletIndex (only used at this one call site; getNonHardwareWalletIndices is still legitimately used by the settings screens).

2. Multisig default threshold drops 2-of-2 → 1-of-2

// multisig_service.dart
static int defaultThreshold(int signerCount) {
  if (signerCount <= 0) return 1;
  return (signerCount * 2 / 3).round().clamp(1, signerCount);
}

The old code enforced a minimum of 2 for any multisig with ≥2 signers. The new formula yields round(2 * 2/3) = 1, so a 2-signer multisig now defaults to 1-of-2 — a single signer can move funds, defeating the purpose of a 2-person multisig. The test was updated to expect {2: 1}, so it's deliberate, but it's a meaningful security/UX downgrade. If unintended, clamp to a signerCount >= 2 ? 2 : 1 minimum.

Concern — remote-config default flips

static const RemoteConfigModel defaults = RemoteConfigModel(
  enableKeystoneHardwareWallet: true,   // was false
  enableHighSecurity: false,            // was true
  enableSwap: false,                    // was true
  enableEncryptedAccount: false,
  enableMultisig: true,                 // was false
  ...
);

Several defaults flipped beyond what the description ("default off / staged rollout") implies:

  • enableMultisig and enableKeystoneHardwareWallet now default on.
  • enableHighSecurity true → false — appears unused anywhere in the app/SDK (only referenced in the model), so likely inert, but the flip looks unintended.
  • enableSwap true → false — this is consumed (home_screen.dart), so swap is hidden by default until remote config re-enables it.

These defaults govern first-launch/fallback behavior, and combined with readLocalConfig now always returning defaults in kDebugMode, they fully drive debug builds. The enableHighSecurity/enableSwap flips in particular read like leftover local test settings — please confirm they're intentional.

Minor / nits

  • Keystone signature isn't verified locally before broadcast. submitExtrinsicWithExternalSignature just packs the scanned signature+pubkey and submits — no check that the signature is valid for the payload or that the scanned publicKey matches account.accountId. The chain rejects a bad scan, but it'd be cleaner to verifyMessage + compare the pubkey in KeystoneScanSignatureScreen._submit and fail early with a clear error. The new generate_keys_test already proves the verify path works.
  • Active-account replacement can land on an encrypted account. Both removeAccount and removeWallet pick firstWhere(... != removed) / remaining.first, which may select an AccountType.encrypted (wormhole) account as the new active account. Worth excluding encrypted accounts from that fallback.
  • Hardcoded English 'Encrypted Account' name in createEncryptedAccount (not localized).
  • run_on_device.sh usage comment says ./run_device.sh but the file is run_on_device.sh.

Looks good

  • getNextFreeAccountIndex lowest-gap fill + encrypted-index exclusion is correct and well-tested.
  • removeAccount now replaces the active account with a genuinely different account instead of blindly accounts[0].
  • removeWallet protects index 0, guards not-found/last-wallet, deletes the mnemonic, and requires two UI confirmations.
  • groupAccounts is a clean, well-covered pure function; adding AccountType.encrypted doesn't break any exhaustive switches.

Verdict: Request changes

Address #1 (wallet-index collision) before merge, decide on #2 (multisig threshold), and confirm the enableHighSecurity/enableSwap default flips are intentional. The remaining items are non-blocking nits.

@n13

n13 commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator Author
  1. bug fixed
  2. not a bug, intentional
  3. concern of new flags - intentional
  4. Local sig verification - doesn't matter, we're just doing work the chain is already doing, and we will not even be able to submit if we have a wrong signature

@n13

n13 commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review

Re-reviewed after 368fc101 fix wallet index bug and your clarifications.

1. Wallet-index collision — fixed.nextNonHardwareWalletIndex is removed and both the software-import and hardware-import paths now use the global nextWalletIndex, so a new wallet always takes a globally unused index. No dangling references, getNonHardwareWalletIndices still serves the settings screens, lints clean. Clean, DRY fix.

2. Multisig threshold — acknowledged, intentional. Fine as a product decision; the user can still raise it in the UI. (For what it's worth, a 2-signer multisig defaulting to 1-of-2 is the one case I'd keep an eye on, but your call.)

3. Remote-config default flips — acknowledged, intentional. 👍

4. Local Keystone signature verification — agreed, non-issue. The chain already verifies and won't accept a bad signature; not worth duplicating.

Remaining items are optional, non-blocking nits (active-account fallback could land on an encrypted account; hardcoded 'Encrypted Account' name; run_on_device.sh comment typo) — fine to defer or skip.


Verdict: Approve / LGTM

The blocking bug is resolved and the rest are deliberate decisions. Good to merge.

byWallet.putIfAbsent(a.walletIndex, () => []).add(a);
}

final walletIndexByAccountId = {for (final a in accounts) a.accountId: a.walletIndex};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow, first time seeing this way of declaring variable.

Not sure. if it's good because it's really hard to read by human.

padding: const EdgeInsets.only(bottom: 12),
child: Text(
title,
style: context.themeText.smallParagraph?.copyWith(color: context.colors.textTertiary, fontSize: 12),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should have used .details instead of smallParagraph. This way, we don't need to override font size.

style: context.themeText.paragraph!.copyWith(
fontSize: 18,
fontWeight: FontWeight.w400,
color: colors.textPrimary,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unnecessary color and font weight.

value: account.name,
onTap: () => _openNameEditor(context, ref, account),
),
Divider(color: colors.toasterBackground, height: 1),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have many of this repeated,

Divider(color: colors.toasterBackground, height: 1)

It might be worth it to have reusable divider. I remember I created that before not sure why it's not exist anymore.

Text(l10n.keystoneScanScanning(_parts.length), style: text.detail?.copyWith(color: Colors.white70)),
if (AppConstants.debugHardwareWallet && !_submitting) ...[
const SizedBox(height: 16),
TextButton(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we create new button on the fly here? we must use standard button component

child: SafeArea(
child: Row(
children: [
IconButton(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same for Icon, we should use quantus icon button.

final threshold = (signerCount * 0.7).round();
final minThreshold = signerCount >= 2 ? 2 : 1;
return threshold.clamp(minThreshold, signerCount);
return (signerCount * 2 / 3).round().clamp(1, signerCount);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why change this?

This change introduce bad default for when multisig only 2 people.

2 * 2 /3 = 1.3333, if we round it it will be 1. Because clamp now 1, the default will be 1. For 2 people multisig, having signer default to 1 when the signers are 2 people, basically useless.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No this is what I wanted it to be, it's the formula I want to use, i think I also made tests for it

2: 1/2
3: 2/3
4: 3/4
5: 3/5
6: 4/6
7: 4/7
... etc

its just a default and a 2 people msig is very rate, who knows why they want to do that, could be they share an account (2 signers), or else they want 2/2... it's ok it can be changed by slider

Just the default needs to be reasonable

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok these are the actual expected ones, all good

const expected = {1: 1, 2: 1, 3: 2, 4: 3, 5: 3, 6: 4, 7: 5, 8: 5, 9: 6, 10: 7};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, if that is what desired then make sense. Just still weird for 2 people to set signer minimum as 1, like it doesn't prevent anyone from spending it.

I dunno why you think it's rate maybe because you have seen people use it. But, from what I know in how people use bank account, couple can set need two signers to proceed balance.

Anyway, I don't really mind being 1 since it can be changed. Not worth fussing it.

@dewabisma dewabisma left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, found some small parts need to be changed.

@dewabisma dewabisma left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@n13 n13 merged commit d210b9f into main Jun 22, 2026
1 check passed
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.

2 participants