Skip to content

V12 SDK Fixes#549

Closed
illuzen wants to merge 36 commits into
mainfrom
illuzen/v12-sdk
Closed

V12 SDK Fixes#549
illuzen wants to merge 36 commits into
mainfrom
illuzen/v12-sdk

Conversation

@illuzen

@illuzen illuzen commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Security review remediation for quantus_sdk addressing 20 findings from the V12 security audit.

Changes

Critical/High Severity

  • #93270 - Multisig approvals now verify call_raw matches indexer-provided recipient/amount before signing
  • #93266 - Account signing validates account type and derived address matches accountId
  • #93267 - Dev mnemonics blocked in release builds

Medium Severity

  • #93172 - Prevent SSRF via path concatenation host override in RedundantEndpointService
  • #93180 - Bound recent address storage (max 256 chars) to prevent DoS
  • #93189 - Fix recovery proxy lookup double-hashing account ID
  • #93193 - Remove hard-coded test address from getInterceptedAccounts
  • #93199 - Exclude wormhole accounts from Senoti registration to preserve privacy
  • #93263 - Validate SS58 prefix matches expected network
  • #93264 - Improve RPC failover robustness with exponential backoff and 5xx detection
  • #93265 - Reject ambiguous locale separators in wire amount parsing
  • #93272 - Use correct pallet for guardian fund recovery
  • #93282 - Prevent stale async init from mutating cleared/disposed state
  • #93096 - Validate exchange rate data (ISO 4217 keys, rate bounds, expiry cap)
  • #93184 - Enforce maxSigners=100 limit in multisig event parsers
  • #93198 - Reject negative fee values in MultisigCreationEvent
  • #93276 - Fix paste detection in DecimalInputFilter

Other Fixes

  • Add request timeout (30s default) to RedundantEndpointService
  • Bound account discovery (gapLimit 1-100, maxScanIndex 10000)
  • Add mutex to prevent account persistence race conditions
  • Fix pagination cursor drift in ChainHistoryService
  • Handle block-number delays in HighSecurityService
  • Bound address length and cache size in HumanReadableChecksumService
  • Fix migration to use each account's walletIndex and handle encrypted accounts
  • Correct pallet indices in payload parser documentation (#93274, #93275)

Testing

All fixes include unit tests verifying the security boundary. Run with:

cd quantus_sdk && flutter test

Notes
Each fix uses minimal code changes rather than wholesale refactoring
Ambiguous wire amounts (e.g., "1,000" or "1.000") now return null rather than guessing
Encrypted accounts throw UnsupportedAccountTypeForSigningException from getKeypair() since they use ZK proofs

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **High Risk**
> Touches authentication-adjacent signing (`getKeypair`), multisig approval trust, migration data loss paths, and network request routing—errors could block legitimate use or, if incomplete, leave spoofing vectors open.
> 
> **Overview**
> Implements **V12 security audit** fixes across `quantus_sdk`, mobile wallet, and cold-wallet signing UI—mostly hardening validation, failover, and user-facing safety around signing and migration.
> 
> **Multisig & signing trust** — Multisig proposals now decode indexer `call_raw` and compare recipient/amount to displayed fields (`CallVerificationStatus`, `isVerified` / `hasVerificationMismatch`). Multisig indexer rows are skipped when predicted on-chain address does not match. `Account.getKeypair()` rejects keystone/external/encrypted types and throws if derived SS58 ≠ stored `accountId`. Dev crystal mnemonics are blocked when `allowDevMnemonics` is false.
> 
> **Network & parsing**`RedundantEndpointService` adds per-request timeouts, failure cooldown/backoff, 5xx failover, and safe URI building to block SSRF via malicious paths. Exchange rates are filtered (ISO 4217 keys, bounded rates, capped expiry). Wire amount parsing returns null on ambiguous single-separator inputs; `DecimalInputFilter` distinguishes true single-char typing from paste/replace and validates grouping via `normalize()`.
> 
> **Migration & high security** — Migration returns per-account `MigrationSuccess` / `MigrationFailure` (per-wallet mnemonic, encrypted/wormhole derivation); mobile UI shows success/failure counts, disables migrate when none succeed, uploads only successes, and avoids clearing old accounts if any fail. Guardian recovery uses `reversibleTransfers.recoverFunds` instead of a recovery utility batch; high-security delay is modeled as timestamp or block delays (`SafeguardDelay`). Reversible payload display uses `ReversibleDelay` with correct units (cold wallet sign screen updated).
> 
> **Other bounds** — Account discovery caps gap limit and max scan index and ignores extra indexer IDs; history pagination advances by raw rows consumed; multisig signers capped and fees must be non-negative; checksum service LRU-bounds cache and address length; connectivity service guards disposed state.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a1a5b0bb8fec1ba021a1af6091f56c4d0086cafc. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

illuzen added 30 commits June 29, 2026 13:34
SECURITY: Fixes critical vulnerability where multisig address prediction
could accept duplicate or oversized signer lists, leading to address
collision attacks.

Changes:
- Rust: Add MIN_SIGNERS=2, MAX_SIGNERS=100 constants
- Rust: Canonicalize signers (sort+dedup) before address derivation
- Rust: Validate uniqueness, threshold bounds, and signer count limits
- Dart: Update _validateSignersAndThreshold to check duplicates and maxSigners
- Dart: Change predictMultisigAddress to require minSigners=2
- Tests: Add comprehensive validation tests for both Rust and Dart

The Rust layer now rejects:
- Fewer than 2 unique signers
- More than 100 signers
- Duplicate signers in input
- Threshold of 0 or exceeding signer count
…s (#93096)

SECURITY: Fixes high severity vulnerability where reversible transfer delay
units (blocks vs milliseconds) were erased, causing users to potentially
misunderstand the actual reversal window.

Changes:
- Add DelayUnit enum (blocks, milliseconds) to distinguish unit types
- Add ReversibleDelay class to preserve both value and unit
- Update TransactionInfo to use reversibleDelay instead of raw int
- Keep reversibleTimeframe as deprecated getter for backwards compatibility
- Update toString() to format delays with correct units:
  - Blocks: '100 blocks'
  - Milliseconds: '5 minutes', '1 hour', '45 seconds', or '500 ms'
- Update cold-wallet-app sign_transaction_screen to use new API
- Add comprehensive tests for delay unit preservation and formatting

Users can now correctly see whether a reversible transfer uses a block-based
or time-based delay, preventing potential confusion about reversal windows.
…#93184)

SECURITY: Fixes high severity vulnerability where a compromised or malicious
indexer could return spoofed multisig records with fraudulent signer lists.

Changes:
- discoverForUser now verifies each discovered multisig's address matches
  the deterministically predicted address from signers/threshold/nonce
- Records with mismatched addresses are silently skipped
- Records with invalid signer data are also skipped

This prevents attackers from tricking users into signing transactions for
fake multisigs where the actual on-chain multisig has different signers
than displayed in the UI.
SECURITY: Fixes high severity vulnerability where clearAll() would
silently delete mnemonics from secure storage, potentially causing
irreversible loss of funds.

Changes:
- clearAll() now only clears SharedPreferences, preserving mnemonics
- Add deleteAllMnemonics() for explicit mnemonic deletion
- Add clearAllIncludingMnemonics() for intentional full reset
- Update SubstrateService.logout() to use clearAllIncludingMnemonics()
- Add documentation warnings about destructive operations
- Add test verifying clearAll preserves secure storage

The separation ensures:
1. Accidental clearAll() calls don't destroy wallet recovery phrases
2. Developers must explicitly choose to delete mnemonics
3. Users' funds are protected even if app state is cleared
SECURITY: Fixes high severity vulnerability where addresses from any
SS58-compatible network were accepted, potentially allowing funds to be
sent to wrong-network addresses.

Changes:
- ss58_to_account_id now validates the address prefix against the
  expected network prefix set via set_default_ss58_prefix
- Returns error instead of panicking for invalid addresses
- Returns descriptive error for prefix mismatch including both prefixes
- Add tests for prefix validation (accept matching, reject different)
- Regenerate Flutter Rust Bridge bindings

The function now throws exceptions for:
- Invalid SS58 addresses (malformed)
- Addresses with wrong network prefix (e.g., Polkadot address when
  expecting Quantus network)

This prevents users from accidentally sending funds to addresses on
other networks by ensuring address validation at the SDK layer.
SECURITY: Fixes high severity vulnerability where RPC failover could fail
to properly recover from endpoint failures, potentially leaving users
unable to transact.

Changes:
- Add consecutiveFailures counter for exponential backoff on failures
- Add cooldown mechanism (5min * failures, max 50min) before retrying
- Add effectiveLatency getter that considers failure state
- Cap penalty latency at 24h to prevent integer overflow (was 365 days)
- Add server error detection (5xx) to trigger failover on HTTP errors
- Sort endpoints before each request attempt
- Fix bestEndpointUrl to sort before returning
- Add recordSuccess/recordFailure methods for cleaner state management

The improved failover now:
1. Properly penalizes failing endpoints with exponential backoff
2. Allows failed endpoints to recover after cooldown period
3. Fails over on HTTP 5xx errors, not just exceptions
4. Always returns the truly best endpoint based on current health
SECURITY: Fixes high severity vulnerability where development mnemonics
with publicly known private keys could be used in production, leading to
potential theft of funds.

Changes:
- Add DevMnemonicNotAllowedException for clearer error handling
- Add allowDevMnemonics static flag (defaults to globalDebug = false)
- Add validateMnemonic() for explicit mnemonic safety checks
- keyPairAtIndex() now throws exception for dev mnemonics in release
- deriveWormhole() also validates dev mnemonic usage
- Add warning log when dev mnemonics are used in debug builds
- Block wormhole derivation for dev mnemonics entirely

The development mnemonics (//Crystal Alice, etc.) produce well-known
keypairs that anyone can access. These are now blocked by default and
only allowed when allowDevMnemonics is explicitly set to true (e.g.,
in test environments).
…3274, #93275)

Fixes incorrect pallet indices in the documentation comment:
- Balances pallet: was 3, corrected to 2
- ReversibleTransfers pallet: was 12, corrected to 13

The actual code was already using the correct indices. This only
fixes the documentation to match reality.
…3272)

SECURITY: Fixes critical bug where guardian pullAllFunds used the wrong
pallet (pallet_recovery) instead of reversibleTransfers.recoverFunds.

The previous implementation:
- Used pallet_recovery calls (initiateRecovery, vouchRecovery, etc.)
- Required independent recovery configuration that was never set up
- Used non-atomic utility.batch which could partially fail
- Would fail at runtime because high-security setup only configures
  the reversibleTransfers pallet, not pallet_recovery

The fix:
- Uses reversibleTransfers.recoverFunds as intended by the pallet
- This is an atomic operation that either succeeds or fails completely
- Cancels all pending transfers (with volume fees) and transfers
  remaining balance to the guardian
- Removes incorrect recovery pallet fee from setup fee calculation

This makes the guardian emergency rescue flow consistent with the
high-security setup and allows guardians to actually recover funds
from compromised accounts as documented.
The previous length-based paste detection (length delta > 1) failed when
users performed select-all replacement pastes with similar-length content.
For example, selecting '1000' and pasting '1,000' (delta=1) was misclassified
as typing, causing the grouping separator to be converted to a decimal
separator, resulting in '1.000' instead of '1000'.

Replace length-delta heuristic with precise single-character insertion
detection that verifies:
- Text is exactly one character longer
- Old selection was collapsed (no text selected)
- Cursor moved exactly one position forward
- Text before and after insertion point is unchanged

This ensures the typing-mode logic (which accepts either . or , as decimal
separator) only applies to true single-character typing. All other edits
(paste, select-all replacement) now correctly strip grouping separators.
Add input validation to ExchangeRatesResult.fromJson to prevent cache
poisoning attacks from malicious or compromised exchange rate responses:

1. Currency code validation: Only accept ISO 4217 format (3 uppercase
   ASCII letters). Rejects malformed keys that could cause issues
   downstream.

2. Rate value validation: Rates must be finite positive numbers within
   reasonable bounds (1e-6 to 1e7). Rejects negative, zero, infinite,
   NaN, and absurdly large/small values that could cause incorrect
   fiat conversions.

3. Expiry cap: Server-supplied expiry is capped at 7 days maximum,
   regardless of what the server returns. Prevents an attacker from
   setting a far-future expiry that would keep poisoned rates in cache
   indefinitely.

Invalid entries are silently filtered rather than causing parse failures,
ensuring partial data from a partially-compromised response is still
usable while malicious entries are discarded.

Add comprehensive test coverage for all validation scenarios.
Add validation to reject oversized signer arrays at the SDK boundary,
preventing DoS attacks from malicious indexer responses.

Changes:
- Add boundedStringListFromJson() helper that enforces max list length
- MultisigCreatedEvent.fromMultisigGraphql: validate signers against
  palletConstants.maxSigners (100) before parsing
- MultisigCreationDraftFields.fromDraft: validate draft.signers length

The pallet declares maxSigners=100, so larger arrays cannot represent
valid on-chain state. Rejecting them early prevents:
- Memory exhaustion from processing impossibly large signer arrays
- Cache pollution with invalid multisig metadata
- Downstream parsing of data that could never exist on-chain

Add comprehensive test coverage for boundary conditions.
Network fees are unsigned balance values on-chain, but the SDK modeled
them as signed BigInts without validation. A malicious indexer or RPC
response could supply negative fees, causing:
- Understated creation costs in preflight checks
- Inflated available balance via negative pending outgoing
- Incorrect totalCost calculations affecting balance providers

Changes:
- Add nonNegativeBigIntFromJson() helper for parsing unsigned values
- MultisigCreatedEvent._networkFeeFromGraphql: validate fee >= 0
- MultisigCreationDraftFields.fromDraft: validate networkFee >= 0
- MultisigCreationEvent constructor: add assertions for both fees

The chain enforces unsigned balances, so the SDK now matches this
constraint at the parsing boundary rather than trusting external data.

Add test coverage for negative fee rejection in all parsing paths.
Account operations (add, update, remove, removeWallet) perform
read-modify-write on a single SharedPreferences JSON blob across await
boundaries. Without synchronization, concurrent async flows could
interleave and silently lose account data:
  1. Flow A reads [X]
  2. Flow B reads [X]
  3. Flow A writes [X, A]
  4. Flow B writes [X, B]  ← A is lost

Changes:
- Add _AsyncMutex class to serialize async critical sections
- Wrap all account persistence methods with _accountsMutex
- Wrap all multisig persistence methods with _multisigMutex
- Add createAndAddAccount() for atomic index allocation + persistence
- Add comprehensive concurrency tests

The mutex ensures operations execute in FIFO order, preventing data loss
when createNewAccount+addAccount, ensureEncryptedAccountsForSoftwareWallets,
discovery persistence, or MigrationService run concurrently.

If two callers race with createNewAccount before addAccount, the second
addAccount now fails with 'Account already exists' rather than silently
overwriting. Use createAndAddAccount() for guaranteed atomic creation.
The discoverAccounts function had no validation on gapLimit and no
maximum scan horizon, allowing:
1. Excessive CPU/memory from large gapLimit values
2. Infinite scanning from malicious indexer responses that mark all
   queried IDs as existing (keeping consecutiveMissing at 0)
3. Response manipulation by returning IDs that weren't queried

Changes:
- Add gapLimit validation: must be between 1 and 100 (default 20)
- Add maxScanIndex (10,000) to guarantee termination regardless of
  indexer responses
- Filter existingIds to only include actually-queried IDs, preventing
  response injection attacks
- Cap batch size to not exceed maxScanIndex
- Expose constants publicly so callers can validate input

The max scan index of 10,000 accounts is far beyond realistic HD wallet
usage while still providing a hard upper bound on resource consumption.

Add test coverage for validation constants.
HTTP requests through RedundantEndpointService (GraphQL, RPC) had no
timeout, allowing stalled connections to hang futures indefinitely.
This blocked account discovery, wallet import, and other critical flows.

Changes:
- Add defaultTimeout (30s) and maxTimeout (5 minutes) constants
- Wrap all HTTP tasks with Future.timeout() in _executeTask
- Add EndpointTimeoutException for clear error identification
- Treat TimeoutException and EndpointTimeoutException as reachability
  errors so they trigger endpoint failover
- Add optional timeout parameter to get(), post(), and rpcTask()
- Clamp caller-supplied timeouts to maxTimeout to prevent abuse

The retry wrapper now advances to the next endpoint after a timeout,
enabling recovery when a backend stalls. The 30-second default is
long enough for legitimate slow responses while preventing indefinite
hangs from malicious or broken backends.

Add test coverage for timeout configuration and exception handling.
The pagination in ChainHistoryService._pageFromEvents computed hasMore
from the raw row count but returned only successfully parsed items.
fetchAllTransactionTypes then advanced nextOtherOffset by the parsed
item count (transfers.length) instead of the raw rows consumed.

This caused cursor drift when rows were skipped (null-parsed):
- Re-fetching already-shown transfers
- Infinite loop when all rows in a window are skipped (offset stays
  fixed while hasMore is true, causing repeated identical requests)

The bug was exploitable: an attacker generating many ae-ms-/ae-multisig-
account_event rows referencing a victim account could trigger sustained
network and battery drain in the history feature.

Changes:
- Add rawRowsConsumed field to _Page and OtherTransfersResult
- Track raw rows consumed in _pageFromEvents (including skipped rows)
- Advance nextOtherOffset and nextScheduledOffset by rawRowsConsumed
- Add tests verifying rawRowsConsumed can differ from items.length

The offset cursor now correctly advances through the underlying data
regardless of how many rows parse to null.
HighSecurityService.getHighSecurityConfig threw ArgumentError when
encountering a BlockNumber delay variant, but the runtime's
BlockNumberOrTimestamp type legitimately includes both variants. The
SDK's own ReversibleTransfersService.delayFromBlocks() helper and
setHighSecurity() both accept block-number delays, and other clients
or direct runtime calls can create them.

This created a crash vector: an attacker could configure a valid
high-security account with a block-number delay and set the victim
as guardian. When the victim's guardian-management UI queried
getHighSecurityConfig for that account, it would throw, breaking the
entire high-security status view.

Changes:
- Add SafeguardDelay sealed class with TimestampDelay and
  BlockNumberDelay subtypes to model both runtime variants
- Update HighSecurityData to use SafeguardDelay instead of Duration
- Add safeguardWindow getter (returns Duration? - null for blocks)
- Add safeguardBlocks getter (returns int? - null for timestamps)
- Preserve backward compatibility via copyWith() and withDuration()
- Update getHighSecurityConfig to map both delay variants correctly
- Add comprehensive tests for the new sealed class hierarchy

UI code can now use pattern matching on data.delay to handle both
types appropriately, or use the convenience getters for simple cases.
HumanReadableChecksumService.getHumanReadableName accepted arbitrary
strings without validation, creating two memory exhaustion vectors:

1. Oversized addresses: No length limit meant huge strings were copied
   into the isolate work queue and potentially cached
2. Unbounded cache: _checkPhraseCache grew without limit, allowing an
   attacker to exhaust memory by querying many unique addresses

Changes:
- Add maxAddressLength (64) constant - SS58 addresses are 47-50 chars
- Reject empty or oversized addresses immediately (before isolate work)
- Replace unbounded Map with _LruCache class (capacity: maxCacheSize=1000)
- LRU cache evicts least-recently-used entries when full
- Only cache successful (non-empty) results to avoid poisoning cache
- Add defensive check for empty words in uppercase transformation

The early rejection of invalid addresses prevents both isolate queue
flooding and cache pollution from attacker-supplied data. The LRU
cache ensures bounded memory even under sustained unique-address load.
…ccounts

MigrationService had three critical flaws:

1. Only fetched mnemonic for baseWalletIndex=0, ignoring accounts from
   other wallets entirely
2. Hardcoded walletIndex=0 in rebuilt accounts, losing the original
   wallet association
3. Used keyPairAtIndex for all accounts, including encrypted/wormhole
   accounts that require a different derivation path

This caused accounts not in wallet 0 to be derived from the wrong seed,
producing addresses that don't correspond to their on-chain funds. After
clearOldAccounts(), the original data was permanently deleted, and two
accounts sharing an index across different wallets would collapse to
identical addresses.

Changes:
- Add MigrationResult sealed class with MigrationSuccess/MigrationFailure
- getMigrationData now fetches mnemonic per account's walletIndex
- Cache mnemonics to avoid repeated secure storage reads
- Detect encrypted accounts (by type or encryptedAccountIndex) and use
  deriveWormholeKeyPair instead of keyPairAtIndex
- Preserve walletIndex and accountType in migrated accounts
- Return failures list from performMigration for UI feedback
- Only clear old accounts if ALL migrations succeeded (prevent data loss)
- Update mobile app to use new API and display failure counts
- Add backward-compatible MigrationAccountData.fromSuccess factory
- Add comprehensive tests for wallet index preservation

The migration now correctly handles multi-wallet setups and encrypted
accounts, with graceful degradation when mnemonics are missing.
…tors (#93265)

Amount parsing accepted malformed or cross-locale numeric strings because
grouping separators were stripped before their placement was validated,
and wire amount parsing guessed decimal vs grouping from ambiguous input.

Problems:
1. Paste flow: '1,5' in US locale was collapsed to '15' instead of rejected
2. LocaleNumberConfig.normalize(): '9,9,9' became '999' (invalid grouping)
3. parseWireAmount(): '1,000' was guessed as 1.0 instead of being ambiguous

These could cause 10x-1000x scale errors in transfers from pasted amounts,
payment URLs, or POS QR codes.

Changes to LocaleNumberConfig:
- Add _validateGrouping() to enforce proper thousands grouping structure
- First group: 1-3 digits, subsequent groups: exactly 3 digits
- Reject grouping separators in fractional part
- normalize() now throws InvalidNumberInputException for invalid grouping

Changes to DecimalInputFilter:
- Stop stripping grouping separators before validation in paste mode
- Let normalize() validate grouping structure and reject malformed input
- Catch InvalidNumberInputException and return oldValue (reject paste)
- Strip grouping separators AFTER validation for display

Changes to NumberFormattingService:
- Add _singleSeparatorWireLocale() to detect ambiguous inputs
- Return null for single-separator amounts with exactly 3 digits after
  (e.g., '1,000' or '1.000' - could be 1 or 1000)
- Multiple occurrences of same separator = definitely grouping
- Both separators present = unambiguous (last one is decimal)

Tests verify all PoC cases now properly reject or return null.
…ir (#93266)

Account.getKeypair() now:
- Rejects keystone/external accounts (no local keys)
- Rejects encrypted accounts (use ZK proofs, not signatures)
- Validates derived address matches stored accountId

SubstrateService.getExtrinsicPayload() now uses account.getKeypair()
instead of directly calling keyPairAtIndex, ensuring all validation
is applied before signing.

This prevents:
- Silent mismatch between displayed address and signing key
- Using wrong derivation path for encrypted accounts
- Signing with locally-derived key for hardware wallet accounts
- Accepting tampered Account records that could redirect transactions
…unt (#93270)

MultisigProposal now verifies that call_raw bytes match the indexer-
provided recipient and amount to prevent spoofed indexer data from
misleading signers about what action they're approving.

Added:
- CallVerificationStatus enum with verified/noCallRaw/decodeError/
  notATransfer/recipientMismatch/amountMismatch states
- callRaw, verificationStatus, verificationError fields
- _verifyCallRaw() that decodes call_raw and compares to indexer data
- isVerified getter (true when verified or notATransfer)
- hasVerificationMismatch getter (true for recipient/amount mismatches)

UI should check isVerified before allowing approval/execution, and
block when hasVerificationMismatch is true (potential indexer spoofing).
…ed state (#93282)

WormholeAddressManager:
- Added generation counter incremented on clear()
- initialize() checks generation after await to detect stale calls
- Prevents secret material from being restored after clear()

ConnectivityService:
- Added _disposed flag checked after await and in callbacks
- Prevents emitting to closed StreamController after dispose()
…172)

RedundantEndpointService.get() and post() now use _buildSafeUri() which:
- Rejects paths containing @ (userinfo syntax)
- Rejects protocol-relative paths (//)
- Rejects absolute URLs (://)
- Verifies resolved host matches original base URL
- Uses Uri.resolve() for safe path resolution

This prevents attackers from redirecting requests to arbitrary hosts
by supplying malicious paths like '@attacker.example/path'.
RecentAddressesService now enforces maxAddressLength=256:
- addAddress() returns false for oversized inputs
- getAddresses() filters out any previously stored oversized entries

This prevents storage abuse from attacker-controlled address strings
passed via QR codes, deep links, or other untrusted sources.
…ble-hashing (#93189)

getProxyRecoveredAccount() was incorrectly using crypto.toAccountId()
to encode the AccountId32 returned by recovery.proxy storage. Since
toAccountId() Poseidon-hashes its input, this double-hashed the
already-derived account ID, producing a wrong address.

Changed to use AddressExtension.ss58AddressFromBytes() which directly
encodes the AccountId32 bytes to SS58 without additional hashing.

This matches the pattern used by other services (reversible_transfers,
high_security) and the documented convention in AddressExtension.
…unts (#93193)

The test code that injected a hard-coded address into the guardian's
intercepted accounts list was left in production code, allowing:
- Fake accounts to appear as entrusted by a guardian
- Guardians to be tricked into signing recovery transactions for
  accounts they don't actually protect

Removed the test code that appended 'qzkaf6wMj...' to non-empty results.
…199)

Encrypted (wormhole) accounts are designed to be unlinkable to the
user's identity for privacy. Registering them with the notification
service would allow Senoti operators or anyone with backend access
to deanonymize wormhole activity by correlating it with the user's
transparent wallet addresses.

Changed registerDevice() to filter out AccountType.encrypted accounts
before sending the address list to Senoti.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 4 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit a1a5b0b. Configure here.

Comment thread mobile-app/lib/features/components/migration_dialog.dart Outdated
Comment thread mobile-app/lib/wallet_initializer.dart
Comment thread quantus_sdk/lib/src/models/multisig_proposal.dart
Comment thread quantus_sdk/lib/src/services/hd_wallet_service.dart
illuzen added 6 commits July 2, 2026 10:26
When every account is a MigrationFailure (e.g., missing mnemonic),
successCount is zero so the Migrate button is disabled. Previously
the sheet was non-dismissible with no escape route since Try later
only appeared after an upload error.

Now shows a Skip button when successCount == 0, allowing users with
old accounts but no usable wallet data to dismiss the modal.
Previously, _performMigration and _tryLater set _needsMigration to
false even when performMigration returned failures. This hid that
migration was incomplete until the next cold start.

Changes:
- Add _hasMigrationFailures state to track partial failures
- Show snackbar notification when some accounts fail to migrate
- Properly capture failures from performMigration in _tryLater

Old accounts are intentionally preserved when failures occur (the
MigrationService already handles this), and migration will retry
on next app launch.
call_raw verification previously returned notATransfer for non-Balances
calls (e.g., utility::batch, System::remark), and isVerified treated
that as safe. However, if the indexer also claimed a recipient/amount,
this allowed spoofed display data to appear verified.

Changes:
- Add CallVerificationStatus.notATransferButIndexerClaimsTransfer
- Return this status when call_raw doesn't decode to a transfer BUT
  the indexer provides recipient/amount (suspicious mismatch)
- Include this status in hasVerificationMismatch check
- notATransfer now only means 'verified' when indexer also has no
  recipient/amount to display

This prevents wrapped or non-balance calls from showing as verified
when the displayed recipient/amount don't match the actual call.
The dev mnemonic guard was using print() which runs in release builds,
unlike the project's logging policy. Changed to debugPrint() which is
stripped from release builds.
Resolve conflict in settings_service.dart by keeping both:
- Mutex wrapper for race condition fix
- Wallet origin and recovery phrase viewed key cleanup

@n13 n13 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.

#93270 - Multisig approvals now verify call_raw matches indexer-provided recipient/amount before signing
#93266 - Account signing validates account type and derived address matches accountId
#93267 - Dev mnemonics blocked in release builds

None of these are real bugs

I don't think it makes sense to do this

The entire app and all flows have to be re-tested after this. Why were 4,000 lines of code added? What do they do?

@n13 n13 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.

Review

Verdict: Request changes.

This PR bundles a handful of genuinely valuable bug fixes with a large amount of defensive hardening whose security impact is overstated, ~600 lines of generated-binding churn, and a red CI. The real fixes are worth landing, but the PR needs trimming and the blocking issues below fixed first.

The security framing is overstated

#93270 (call_raw verification) — the headline critical — does not hold up:

  • call_raw, transfer_to, and transfer_amount all come from the same indexer record. A compromised indexer serves a self-consistent lie (fake call_raw matching the fake display fields) and verification passes. This is a self-consistency check within a single untrusted source, not a trust boundary. It only catches an attacker who tampers the display columns but forgets call_raw.
  • Nothing consumes it. isVerified / hasVerificationMismatch are referenced nowhere outside the model and its tests — no approve/execute UI checks them. As merged, this changes no behavior at all.
  • The approve extrinsic commits only to proposalId (the PR's own test demonstrates this), so the meaningful fix is to verify the proposal against on-chain storage via RPC before signing, and have the approval UI block on that. Either do that, or don't call this a critical fix.

By contrast, the discoverForUser check (skip records whose address ≠ locally computed hash(signers, threshold, nonce)) is sound — the address cryptographically commits to the signer set. Good fix, but see blocking issue 6.

Similar pattern elsewhere: the SSRF fix guards paths that no call site passes untrusted data into (all callers use post(body:) with fixed/empty paths); the exchange-rate bounds still accept wrong-but-plausible rates from a compromised rate API; the DoS bounds (address length, LRU cache, gap limit) are local-resource hygiene. Fine to have, but low severity.

The fixes actually worth landing are mostly correctness, not security — and they're good:

  • Migration fix (wallet-index/encrypted derivation): the old code derived accounts from the wrong seed and then cleared the old data — permanent fund loss. Most impactful fix in the PR.
  • #93272 guardian recovery used a pallet that was never configured — the rescue path could not work.
  • #93189 recovery proxy double-hashing (wrong address returned).
  • #93193 leftover hard-coded test address.
  • clearAll() no longer silently deleting mnemonics; pagination cursor drift; #93199 Senoti wormhole privacy; #93263 SS58 prefix validation (panic→error is also a good change).

Blocking issues

  1. CI Analyze is red (melos exec flutter analyze, warnings are fatal). mobile-app: 3× invalid_return_type_for_catch_error, 1× unused _hasMigrationFailures. quantus_sdk: unused wormholeKeyPair local, unused imports/locals in new tests.
  2. RecentAddressesService.addAddress return type change (Future<void>Future<bool>) breaks the three .catchError((e) => debugPrint(...)) call sites — the handler now must return FutureOr<bool>; if the future ever fails, the handler itself throws a runtime TypeError inside unawaited(...). Fix the call sites or keep the void return.
  3. Account.getKeypair() encrypted branch is dead code: it derives the wormhole keypair, then unconditionally throws. The exception message also claims encrypted accounts can derive keypairs, contradicting the behavior. Delete the derivation and fix the message/doc comment.
  4. _hasMigrationFailures is written but never read — either surface it in UI or drop it.
  5. Wire-amount ambiguity check is over-broad: parseWireAmount('1234.567') returns null, but a grouping reading is impossible there (first group would be 4 digits). Only treat single-separator + 3 trailing digits as ambiguous when the integer part is 1–3 digits. As written, this will reject legitimate payment-URL/QR amounts.
  6. discoverForUser silently continues on mismatch/parse failure with no logging — a legit multisig disappearing from a user's list with zero diagnostics will be unsupportable. Also, when the indexer omits nonce, the code assumes defaultMultisigNonce; a legit multisig created with a different nonce would be silently hidden. Log every skip with the reason.
  7. _buildSafeUri changes URL semantics: empty path now produces a trailing slash (…/v1/graphql/ — I verified the production Hasura tolerates this, 200), and a leading-/ path now replaces the base path instead of appending (old: $url$path). Latent breakage for any future endpoint with a path prefix; the double-negative validation logic should also be simplified — it's hard to convince yourself it's airtight.
  8. TransactionInfo's deprecated reversibleTimeframe constructor parameter is accepted and silently dropped. Remove it or make it populate reversibleDelay.
  9. Diff noise: frb_generated.* reformatting (~600 lines) and pubspec.lock downgrades (meta 1.18.0→1.17.0, test_api 0.7.11→0.7.10) suggest the bindings were regenerated with a different Flutter/FRB toolchain than the repo's. Regenerate with the pinned toolchain so the diff shows only the real change (the ss58_to_account_id error codec).
  10. Minor: PR description says dev mnemonics are "blocked in release builds", but allowDevMnemonics defaults to AppConstants.globalDebug, which is const false — they're blocked everywhere unless code is edited. Fine as behavior, but say what it does.

Verification performed

  • flutter test in quantus_sdk: 220 passed; 3 test files (account_extension_test, generate_keys_test, recovery_proxy_encoding_test) require the native Rust dylib and can't run from a clean checkout — cargo build fails to resolve the yanked core2 ^0.3 (via KeystoneHQ libflate) because rust/Cargo.lock isn't committed. Worth fixing separately; note CI isn't running these tests either.
  • flutter analyze: cold-wallet-app clean; mobile-app and quantus_sdk warnings as listed above (matches the failing CI job).
  • Rust changes reviewed: multisig canonicalization/threshold validation and SS58 prefix validation look correct.

Recommendation

Split out and land the genuine bug fixes (migration, guardian recovery pallet, proxy double-hash, test-address removal, clearAll, pagination) in a slim PR. For #93270, either make verification meaningful — check against on-chain proposal state and wire isVerified/hasVerificationMismatch into the approve/execute UI — or drop the 200 lines of unused machinery. Revert the generated-file and lockfile churn.

@n13

n13 commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Closing in favor of #552, which lands only the correctness fixes from this PR (migration derivation, guardian recovery, recovery proxy encoding, test address removal, clearAll mnemonic handling, pagination cursor, Senoti privacy, SS58 validation). See the review above for details on the remaining findings.

@n13 n13 closed this Jul 2, 2026
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