Skip to content

CP-13198: Ledger Staking Delegation Flow#3543

Merged
B0Y3R-AVA merged 29 commits intomainfrom
boyer/ledger-staking-delegation
Feb 16, 2026
Merged

CP-13198: Ledger Staking Delegation Flow#3543
B0Y3R-AVA merged 29 commits intomainfrom
boyer/ledger-staking-delegation

Conversation

@B0Y3R-AVA
Copy link
Contributor

@B0Y3R-AVA B0Y3R-AVA commented Feb 4, 2026

Description

Contributes to Ticket: CP-13198

Summary

Adds full Ledger hardware wallet support for the staking delegation flow, including a two-phase transaction modal and real-time progress
tracking.

Changes

New Files

  • LedgerStakingProgressScreen.tsx - Progress UI with animated dots showing current step (EXPORT_C → IMPORT_P → DELEGATE)
  • ledgerStakingProgressCache.ts - In-memory cache for tracking progress state between components
  • withLedgerStakingProgressCache.tsx - HOC wrapper for the progress screen
  • ledgerStakingProgress.tsx - New route for the progress modal

Modified Files

File Changes
LedgerReviewTransactionScreen.tsx Two-phase modal: device connection verification → progress tracking
addStake/confirm.tsx Ledger detection, show review modal before staking, improved error handling with alerts
useDelegation.ts Progress tracking callbacks that update cache during each step
LedgerWallet.ts Refactored signAvalancheTransaction() to use direct AppAvax.sign() with device/app checks
computeDelegationSteps/types.ts Added EXPORT_P and IMPORT_C operation types for claim flow
getInternalExternalAddrs.ts Address derivation fixes for Ledger accounts
ledgerParamsCache.ts Added stakingProgress params support
ledger/utils/index.ts Updated showLedgerReviewTransaction() to accept staking progress config

Features

  1. Two-phase Ledger modal - First verifies device connection + Avalanche app, then shows progress
  2. Progress dots UI - Visual indicator showing current step of multi-step transaction
  3. Better error handling - Closes modals and shows alerts for insufficient funds / other errors
  4. Retry support - "Funds stuck" scenario allows retry with Ledger reconnection
  5. Direct signing - Bypasses SDK's ZondaxProvider which has module resolution issues in React Native

Testing

iOS: 7349
Android: 7350

  • Connect Ledger device
  • Navigate to staking flow, enter amount and duration
  • Verify device connection screen appears
  • Approve on device, verify progress dots update for each step
  • Complete full delegation flow

Screenshots/Videos

Screen.Recording.2026-01-29.at.4.52.33.PM.mov

Testing

Dev Testing (if applicable)

  • Using a ledger device
  • Import a ledger wallet fresh (IMPORTANT changes have been made to addresses we generate for this)
  • Make Sure you have enough avax to test staking on main net + test net
  • Initiate a staking tx via you're ledger wallet
  • Confirm you are walked through all three steps
  1. Export C
  2. Import P
  3. Delegate

QA Testing (if applicable)

  • Provide instructions for QA to test this feature thoroughly
  • State expected behavior / acceptance criteria

Checklist

Please check all that apply (if applicable)

  • I have performed a self-review of my code
  • I have verified the code works
  • I have included screenshots / videos of android and ios
  • I have added testing steps
  • I have added/updated necessary unit tests
  • I have updated the documentation

@B0Y3R-AVA B0Y3R-AVA changed the title staking delegation + ui flow for ledger CP-13198: Ledger Staking Delegation Flow Feb 4, 2026
@B0Y3R-AVA B0Y3R-AVA force-pushed the boyer/store-ledger-xpub-per-account branch 2 times, most recently from 534e6de to acf7bee Compare February 5, 2026 17:49
Base automatically changed from boyer/store-ledger-xpub-per-account to main February 5, 2026 18:39
@B0Y3R-AVA B0Y3R-AVA force-pushed the boyer/ledger-staking-delegation branch from 551a09b to 9849ea4 Compare February 5, 2026 19:26
Copy link
Contributor

Choose a reason for hiding this comment

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

are we using this screen in this PR? I can't find any code to navigate to this screen.

@B0Y3R-AVA B0Y3R-AVA force-pushed the boyer/ledger-staking-delegation branch from 4a18d32 to d5ac616 Compare February 5, 2026 22:38
} catch (error) {
// State not available yet, will retry on next poll
}
}, 200) // Poll every 200ms
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we make the progress tracking more deterministic instead of polling the cache every 200ms? wondering if there's a cleaner way to propagate the state updates (i.e. callback-based).

onProgress = params.stakingProgress?.onProgress
} catch {
// No ledger params cache available, skip progress callback
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Referencing ledgerParamsCache directly from useDelegation creates a coupling between delegation logic and Ledger. Passing onProgress as a parameter to issueDelegation would be more explicit and keep the hook Ledger-agnostic.

@B0Y3R-AVA B0Y3R-AVA force-pushed the boyer/ledger-staking-delegation branch from ef55eb5 to b2556eb Compare February 6, 2026 18:32
onghwan
onghwan previously approved these changes Feb 6, 2026
@B0Y3R-AVA B0Y3R-AVA force-pushed the boyer/ledger-staking-delegation branch from b690d57 to 34ae67f Compare February 9, 2026 21:32
Copy link
Contributor

@ruijialin-avalabs ruijialin-avalabs left a comment

Choose a reason for hiding this comment

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

nice!

@B0Y3R-AVA B0Y3R-AVA force-pushed the boyer/ledger-staking-delegation branch from 3acb0c3 to 37d1f08 Compare February 10, 2026 18:07
Copilot AI review requested due to automatic review settings February 12, 2026 22:20
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request adds comprehensive Ledger hardware wallet support for the staking delegation flow. It replaces the previous cache-based parameter passing system with a Zustand store, implements a two-phase modal UI (connection verification → transaction progress tracking), and refactors the Avalanche transaction signing to work directly with the Ledger device API instead of going through the SDK's provider abstraction.

Changes:

  • Implements a two-phase Ledger transaction review modal with real-time progress tracking for multi-step staking operations (Export C-Chain → Import P-Chain → Delegate)
  • Migrates from in-memory cache system to Zustand store for managing Ledger transaction parameters
  • Refactors signAvalancheTransaction in LedgerWallet to bypass SDK's ZondaxProvider and directly use AppAvax for improved React Native compatibility
  • Adds address normalization logic to handle cross-network HRP prefix differences in XP address dictionaries
  • Implements proper cleanup for Ledger addresses when removing wallets

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
LedgerReviewTransactionScreen.tsx Refactored to support two phases: connection verification and progress tracking with animated dots
ledgerParamsCache.ts Removed - replaced with Zustand store
withLedgerParamsCache.tsx Removed - no longer needed with direct store access
store.ts Added ephemeral Zustand store for Ledger review transaction params with staking progress support
utils/index.ts Added executeLedgerStakingOperation helper and updated showLedgerReviewTransaction signature
LedgerWallet.ts Refactored signAvalancheTransaction to use AppAvax directly with device/app verification
confirm.tsx Added Ledger detection, modal integration, and improved error handling with alerts
useDelegation.ts Added progress callback support that reports current step and operation
useIssueDelegation.ts Added onProgress parameter propagation
DelegationContext.tsx Added OnDelegationProgress type definition
types.ts Added EXPORT_P and IMPORT_C operations for claim flow
getInternalExternalAddrs.ts Added address normalization to handle different network HRP prefixes
transformXPAddresses.ts Updated to strip address prefixes for SDK consistency
AvalancheWalletService.ts Fixed fromAddresses parameter to include P- prefix in simulation
slice.ts Added removeLedgerAddress action for cleanup
thunks.ts Added call to removeLedgerAddress when removing accounts
SelectAccounts.tsx Added wallet badge display for multi-wallet scenarios
screenOptions.tsx Added ledgerModalScreensOptions with iOS-specific freezeOnBlur handling
useLedgerWallet.ts Fixed addressCoreEth to use actual address instead of empty string
_layout.tsx Updated ledgerReviewTransaction screen to use new modal options
Comments suppressed due to low confidence (1)

packages/core-mobile/app/new/common/hooks/send/utils/getInternalExternalAddrs.ts:95

  • The reduce function on lines 71-95 uses array spreading to accumulate indices (...accumulator.internalIndices, ...accumulator.externalIndices). If there are many UTXOs, this could create performance issues as each iteration creates new arrays. Additionally, the result may contain duplicate indices if multiple UTXOs use the same address. Consider using a Set to collect unique indices and converting to an array at the end, or use push() for better performance.
  return [...utxosAddrs].reduce(
    (accumulator, address) => {
      // This can happen when the CoreEth address owns a UTXO.
      const xpAddressDictElement = normalizedDict[address]
      if (xpAddressDictElement === undefined) {
        return accumulator
      }
      const { space, index } = xpAddressDictElement

      return {
        internalIndices: [
          ...accumulator.internalIndices,
          ...(space === 'i' ? [index] : [])
        ],
        externalIndices: [
          ...accumulator.externalIndices,
          ...(space === 'e' ? [index] : [])
        ]
      }
    },
    {
      externalIndices: [] as number[],
      internalIndices: [] as number[]
    }
  )

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

*/
export const ledgerModalScreensOptions: NativeStackNavigationOptions = {
...modalScreensOptions,
freezeOnBlur: Platform.OS === 'ios' ? false : true,
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

The freezeOnBlur property is set to true explicitly on line 96 for Android, but true is the default value in React Navigation. You can simplify this to just omit the property for Android or use a cleaner ternary expression. Consider: freezeOnBlur: Platform.OS === 'ios' ? false : undefined to rely on the default.

Suggested change
freezeOnBlur: Platform.OS === 'ios' ? false : true,
freezeOnBlur: Platform.OS === 'ios' ? false : undefined,

Copilot uses AI. Check for mistakes.
const signingPaths =
chainAlias === 'C'
? [`0/${accountIndex}`]
: (hasIndices ? externalIndices : [0]).map(i => `0/${i}`)
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

The fallback logic for empty externalIndices uses [0] (line 542), but this creates signing paths like ['0/0']. However, when externalIndices is provided, it maps each index i to 0/${i}. This inconsistency means that an empty array and an array with [0] will produce different results. Consider using .map(i => '0/${i}') consistently or clarifying the intended behavior.

Suggested change
: (hasIndices ? externalIndices : [0]).map(i => `0/${i}`)
: hasIndices
? externalIndices.map(i => `0/${i}`)
: ['0/0']

Copilot uses AI. Check for mistakes.
Comment on lines +177 to +179
setTimeout(() => {
stakingProgress.onComplete()
}, 500) // Brief delay to show final state
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

The setTimeout on lines 177-179 creates a 500ms delay before calling onComplete, but there's no cleanup function. If the component unmounts during this delay (e.g., user navigates away), the callback will still execute. Store the timeout ID and clear it in a cleanup function to prevent calling onComplete after unmount.

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +72
const ledgerAddress = state.ledgerAddresses[accountId]
if (ledgerAddress) {
delete state.ledgerAddresses[accountId]
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

The conditional check on line 70 is unnecessary. The delete operation is safe to perform even if the key doesn't exist in the object. The check reads the value just to see if it exists before deleting it, which is redundant. You can simplify this to just: delete state.ledgerAddresses[accountId].

Suggested change
const ledgerAddress = state.ledgerAddresses[accountId]
if (ledgerAddress) {
delete state.ledgerAddresses[accountId]
}
delete state.ledgerAddresses[accountId]

Copilot uses AI. Check for mistakes.

// Get chain alias from transaction VM
const vmName = transaction.tx.getVM()
const chainAlias = vmName === 'EVM' ? 'C' : 'X' // X for both X-chain and P-chain
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

The comment on line 526 states "X for both X-chain and P-chain" but this might be incorrect. The Ledger Avalanche app typically distinguishes between P-chain and X-chain transactions. Using 'X' as the chainAlias for P-chain transactions could cause signing issues. Verify that the hw-app-avalanche library expects 'X' for P-chain transactions, or if 'P' should be used instead.

Suggested change
const chainAlias = vmName === 'EVM' ? 'C' : 'X' // X for both X-chain and P-chain
const chainAlias =
vmName === NetworkVMType.EVM || vmName === 'EVM'
? 'C'
: vmName === NetworkVMType.PLATFORM ||
vmName === 'PLATFORM' ||
vmName === 'PlatformVM' ||
vmName === 'P'
? 'P'
: 'X' // default to X-chain (AVM) for non-EVM, non-Platform VMs

Copilot uses AI. Check for mistakes.
useBalanceInCurrencyForAccount(account.id)
const { formatCurrency } = useFormatCurrency()
const accountData = useSelector(selectAccountById(account.id))
const wallet = useSelector(selectWalletById(accountData?.walletId ?? ''))
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

The selectAccountById and selectWalletById selectors are called unconditionally on lines 128-129, but the account might not exist, and wallet might be undefined. While this is handled with optional chaining on line 129 (accountData?.walletId ?? ''), calling selectWalletById with an empty string when there's no wallet could be inefficient. Consider adding an early return or conditional rendering if accountData is undefined.

Suggested change
const wallet = useSelector(selectWalletById(accountData?.walletId ?? ''))
const walletId = accountData?.walletId
const walletSelector = useMemo(
() => (walletId ? selectWalletById(walletId) : () => undefined),
[walletId]
)
const wallet = useSelector(walletSelector)

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +57
// TODO: Consider using AnalyticsService here to track successful Ledger transactions
Logger.info('Ledger transaction completed')
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

The TODO comment on line 56 suggests adding AnalyticsService tracking for successful Ledger transactions. While this is already captured in the onDelegationSuccess callback in confirm.tsx (line 249 with 'StakeDelegationSuccess'), it would be beneficial to track Ledger-specific success events to distinguish between regular wallet and Ledger wallet staking operations for analytics purposes.

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +39
} catch {
// If we can't parse the address (e.g., it's not a valid bech32),
// keep it as-is for backward compatibility
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

The normalizeXpAddressDict function on lines 27-45 catches all errors from formatAvalancheAddress and falls back to keeping the original address. However, this silent error handling could hide bugs where addresses are in an unexpected format. Consider logging a warning when an address cannot be normalized to help with debugging.

Suggested change
} catch {
// If we can't parse the address (e.g., it's not a valid bech32),
// keep it as-is for backward compatibility
} catch (error) {
// If we can't parse the address (e.g., it's not a valid bech32),
// keep it as-is for backward compatibility, but log a warning
// to make unexpected address formats visible during debugging.
console.warn(
'normalizeXpAddressDict: failed to normalize Avalanche address, keeping original address key',
{ address, isTestnet, error }
)

Copilot uses AI. Check for mistakes.
Comment on lines 530 to 532
// For C chain (EVM): m/44'/60'/0'
const accountPath =
chainAlias === 'C' ? `m/44'/60'/0'` : `m/44'/9000'/${accountIndex}'`
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

The account path for C-chain on line 532 uses m/44'/60'/0' which is hardcoded to account index 0. This doesn't use the accountIndex parameter that was passed in. For C-chain transactions from different account indices, this could cause signature verification failures or signing with the wrong key. Consider using m/44'/60'/${accountIndex}' for consistency with X/P chain handling.

Suggested change
// For C chain (EVM): m/44'/60'/0'
const accountPath =
chainAlias === 'C' ? `m/44'/60'/0'` : `m/44'/9000'/${accountIndex}'`
// For C chain (EVM): m/44'/60'/{accountIndex}'
const accountPath =
chainAlias === 'C' ? `m/44'/60'/${accountIndex}'` : `m/44'/9000'/${accountIndex}'`

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

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

@B0Y3R-AVA , should we adjust this?

Comment on lines 158 to 198
useEffect(() => {
if (approvalTriggeredRef.current) return

const handleApproveTransaction = async (): Promise<void> => {
if (deviceForWallet && isConnected) {
try {
approvalTriggeredRef.current = true
await LedgerService.openApp(ledgerAppName)
await onApprove()
} finally {
approvalTriggeredRef.current = false
if (
deviceForWallet &&
isConnected &&
isAvalancheAppOpen &&
phase === 'connection' &&
onApprove
) {
if (stakingProgress) {
// Create progress callback that updates local state
const onProgress = (
step: number,
operation: Operation | null
): void => {
setCurrentStep(step)
setCurrentOperation(operation)

// Auto-complete when all steps are done
if (step >= stakingProgress.totalSteps) {
setTimeout(() => {
stakingProgress.onComplete()
}, 500) // Brief delay to show final state
}
}
// Transition to progress phase
setPhase('progress')
// Start the transaction process with progress callback
onApprove(onProgress)
} else {
// No staking progress tracking, just approve and let the caller handle navigation
onApprove()
}
}
handleApproveTransaction()
}, [deviceForWallet, onApprove, isConnected, ledgerAppName])
}, [
deviceForWallet,
isConnected,
isAvalancheAppOpen,
phase,
stakingProgress,
onApprove
])
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

The useEffect on lines 158-198 will trigger onApprove every time the dependencies change while conditions are met. This could cause onApprove to be called multiple times if any of the dependencies (deviceForWallet, isConnected, isAvalancheAppOpen, phase, stakingProgress, onApprove) change. Consider adding a ref or state flag to ensure onApprove is only called once per connection session.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings February 13, 2026 15:01
…acking step progress state for the ledgerReivewTransaction screen, decoupled delegation logic from ledger completely
…llet connect, matches logic in recent contacts where we only show wallet info if theres more than one wallet
@B0Y3R-AVA B0Y3R-AVA force-pushed the boyer/ledger-staking-delegation branch from 750ed8c to 0902a0c Compare February 16, 2026 21:13
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +139 to +142
// Check if Avalanche app is open
const appType = LedgerService.getCurrentAppType()
const isAvaxApp = appType === LedgerAppType.AVALANCHE
setIsAvalancheAppOpen(isAvaxApp)
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

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

checkDeviceReady hard-codes LedgerAppType.AVALANCHE, but this modal is also used from ApprovalController for non-Avalanche Ledger signing. This will prevent the UI from ever transitioning out of the connection phase when ledgerAppName is ETHEREUM/SOLANA/BITCOIN/etc. Compare appType against the computed ledgerAppName instead of a constant (and update naming accordingly).

Copilot uses AI. Check for mistakes.
},
onReject: () => {
// User cancelled Ledger connection
Logger.info('Ledger transaction rejected')
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

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

executeLedgerStakingOperation passes an onReject that only logs. In LedgerReviewTransactionScreen, the Cancel/back/gesture handlers call onReject, so cancelling the Ledger modal during staking will not dismiss the modal or return to the previous screen. Make onReject close the modal (e.g., router.back() or stakingProgress.onCancel()).

Suggested change
Logger.info('Ledger transaction rejected')
Logger.info('Ledger transaction rejected')
router.back()

Copilot uses AI. Check for mistakes.
Comment on lines +670 to +690
await LedgerService.openApp(LedgerAppType.AVALANCHE)

// First ensure we're connected to the device
Logger.info('Ensuring connection to Ledger device...')
try {
await LedgerService.ensureConnection(this.deviceId)
Logger.info('Successfully connected to Ledger device')
} catch (error) {
Logger.error('Failed to connect to Ledger device:', error)
throw new Error(
'Please make sure your Ledger device is nearby, unlocked, and Bluetooth is enabled.'
)
) {
throw new Error('Unable to sign avalanche transaction: invalid signer')
}

const txToSign = {
tx: transaction.tx,
externalIndices: transaction.externalIndices,
internalIndices: transaction.internalIndices
// Now ensure Avalanche app is ready
Logger.info('Ensuring Avalanche app is ready...')
try {
await LedgerService.waitForApp(
LedgerAppType.AVALANCHE,
LEDGER_TIMEOUTS.APP_WAIT_TIMEOUT
)
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

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

LedgerService.openApp(...) is called before ensuring a transport connection. Since openApp requires an initialized transport (and is best-effort), calling it before ensureConnection makes it a no-op in the common case and reduces the chance of auto-opening the app. Reorder to ensureConnectionopenAppwaitForApp, and consider reusing the transport returned by getTransport() to avoid double ensureConnection calls.

Copilot uses AI. Check for mistakes.
xpAddressDictionary: XPAddressDictionary
} {
// Derive xpAddresses with fallback
// Note: All addresses should be stripped of HRP prefix for consistency with SDK expectations
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

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

The new comment says addresses are stripped of the "HRP prefix", but stripAddressPrefix only removes the chain prefix (X-/P-/C-). Update the comment to avoid confusion about what normalization is actually being applied.

Suggested change
// Note: All addresses should be stripped of HRP prefix for consistency with SDK expectations
// Note: All addresses should be normalized by stripping the chain prefix (e.g. X-/P-/C-) for consistency with SDK expectations

Copilot uses AI. Check for mistakes.
@B0Y3R-AVA B0Y3R-AVA merged commit b226c93 into main Feb 16, 2026
4 checks passed
@B0Y3R-AVA B0Y3R-AVA deleted the boyer/ledger-staking-delegation branch February 16, 2026 21:28
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.

4 participants