From 60ec1335e1c38e870b97289efac041b0f083e6e7 Mon Sep 17 00:00:00 2001 From: zancas Date: Tue, 28 Jul 2026 22:34:07 -0700 Subject: [PATCH 1/2] refactor!: retire the migration cadence surface end to end The ZIP 318 Poisson schedule in zingolib draws every broadcast delay itself, so the per-window cadence choice is a control that always refuses. This removes the surface that offered it, across every layer. The zingolib pin moves from upstream dev (3d2f2aa0) to the mobile stack's settled branch, indexer_census (ba03ec07), and the lock is settled to match. Upstream dev still lacks the mobile-adoption work this tree builds on; the pin comment records the conflict policy from the pin collaboration protocol. In the FFI the cadence API survives shape-stably where zingolib still exposes it: start_ironwood_migration passes per_bucket as None and the retired CadenceFixed variant is mapped for exhaustiveness only. In Rust, reschedule_parts and the app's MigrationCadenceFixed error are gone, the status JSON drops per_bucket, and the error funnel maps the mixnet-refusal variants the same upstream work introduced (MixnetNotReady, NoEligibleBroadcastIndexer, MigrationBroadcastTargetIsSyncEndpoint). The uniffi UDL and the checked-in Kotlin binding are regenerated to match, the Android and iOS bridge methods follow, and so do their outcome-table tests. In the app, the MigrationCadence chooser screen is deleted and splitting completion navigates straight to the schedule review. Progress counts parts directly on the status screen and the history banner, since each part now has its own drawn window. The status types adopt the wake-point vocabulary (RPCWakePointType), the dev-era BlockTime constants and the split-report types leave with their tests, and the migrationcadence translation blocks are removed from all five languages. The History snapshots are retaken; they land byte-identical to the feat/nym tree's. Verified locally: cargo check and clippy clean against the new pin, tsc clean, the migration-area jest suites pass (25 tests), and the retaken snapshots match nym_linear exactly. Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 11 +- __tests__/BlockTime.unit.test.ts | 84 --- ...nwoodMigrationBanner.broadcastRun.unit.tsx | 149 ----- .../__snapshots__/History.snapshot.tsx.snap | 406 ++++++------ ...lletBackend.ironwoodMigration.unit.test.ts | 18 +- ...alletBackend.migrationRouting.unit.test.ts | 74 +-- .../java/org/ZingoLabs/Zingo/RPCModule.kt | 18 +- .../org/ZingoLabs/Zingo/FfiOutcomeTest.kt | 1 - app/AppState/const/BlockTime.ts | 64 -- app/AppState/enums/RouteEnum.ts | 1 - app/AppState/index.js | 10 - app/LoadedApp/LoadedApp.tsx | 8 - app/RPCModule/RPCModule.ts | 18 +- app/translations/en.json | 21 - app/translations/es.json | 21 - app/translations/pt.json | 21 - app/translations/ru.json | 21 - app/translations/tr.json | 21 - app/types/NavigationTypes.ts | 5 +- app/walletBackend/ffi.ts | 1 - app/walletBackend/index.ts | 16 +- app/walletBackend/modules/DataService.ts | 39 -- app/walletBackend/types/RPCBatchReportType.ts | 2 +- .../types/RPCMigrationStatusType.ts | 28 +- .../types/RPCSplitOutcomeType.ts | 13 - app/walletBackend/types/RPCSplitStatusType.ts | 17 - .../types/RPCWindowReportType.ts | 26 - app/walletBackend/utils/migrationRouting.ts | 49 -- app/walletBackend/utils/walletUtils.ts | 63 +- .../components/IronwoodMigrationBanner.tsx | 603 +++++++----------- .../MigrationCadence/MigrationCadence.tsx | 446 ------------- components/MigrationCadence/index.js | 3 - .../MigrationSchedule/MigrationSchedule.tsx | 149 +---- .../MigrationSplitPlan/MigrationSplitPlan.tsx | 238 +++---- .../MigrationSplitting/MigrationSplitting.tsx | 198 +++--- .../MigrationStatus/MigrationStatus.tsx | 248 ++----- ios/RPCModule.swift | 58 +- ios/RPCModuleBridge.m | 14 - ios/ZingoTests/ZingoTest.swift | 1 - rust/Cargo.lock | 28 +- rust/Cargo.toml | 21 +- rust/lib/src/lib.rs | 212 +----- rust/lib/src/zingo.udl | 18 +- 43 files changed, 848 insertions(+), 2615 deletions(-) delete mode 100644 __tests__/BlockTime.unit.test.ts delete mode 100644 __tests__/IronwoodMigrationBanner.broadcastRun.unit.tsx delete mode 100644 app/AppState/const/BlockTime.ts delete mode 100644 app/walletBackend/types/RPCSplitOutcomeType.ts delete mode 100644 app/walletBackend/types/RPCSplitStatusType.ts delete mode 100644 app/walletBackend/types/RPCWindowReportType.ts delete mode 100644 components/MigrationCadence/MigrationCadence.tsx delete mode 100644 components/MigrationCadence/index.js diff --git a/CONTEXT.md b/CONTEXT.md index d60a15ed4..84bd3a41c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -88,12 +88,13 @@ round's inputs nor its outputs, so planning over it is wrong. A part whose window became unwitnessable and which moved itself to a coming window. Rendered as a plain explanatory sentence, never an error. -**Stranded**: -Value left out of the plan because moving it would cost more than it carries. -Disclosed at consent. - **Residual**: -What remains when the migration completes; disclosed on completion. +Value left unmigrated because moving it would cost more than it carries — +projected and disclosed at consent, reported again on completion. A plan +never loses value silently: migrated + fees + residual equals the starting +balance. +_Avoid_: stranded (retired 2026-07-26 when zingolib unified the plan-time +and completion-time vocabulary on "residual") **Consent**: The single user approval of an exact plan hash before anything is signed or diff --git a/__tests__/BlockTime.unit.test.ts b/__tests__/BlockTime.unit.test.ts deleted file mode 100644 index 271daa8a4..000000000 --- a/__tests__/BlockTime.unit.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * @format - */ - -import { - TARGET_BLOCK_SPACING_SECONDS, - estimatedTimestampMs, - foldBlockSpacing, - windowTargetHeight, -} from '../app/AppState'; -import { RPCBroadcastWindowType } from '../app/walletBackend/types/RPCMigrationStatusType'; - -describe('foldBlockSpacing', () => { - test('first accepted sample becomes the estimate', () => { - expect(foldBlockSpacing(null, 10)).toBe(10); - }); - - test('later samples fold in without replacing the estimate', () => { - const folded = foldBlockSpacing(75, 10) as number; - expect(folded).toBeLessThan(75); - expect(folded).toBeGreaterThan(10); - }); - - test('a staged height jump reads as sub-second spacing and is rejected', () => { - // 400 blocks landing in one 5s poll gap. - expect(foldBlockSpacing(75, 5 / 400)).toBe(75); - expect(foldBlockSpacing(null, 5 / 400)).toBeNull(); - }); - - test('a paused miner reads as a stall and is rejected', () => { - expect(foldBlockSpacing(10, 3600)).toBe(10); - }); - - test('converges onto a fast test rig within a few blocks', () => { - let estimate: number | null = 75; - for (let i = 0; i < 12; i++) { - estimate = foldBlockSpacing(estimate, 10); - } - expect(estimate as number).toBeLessThan(11); - }); -}); - -describe('windowTargetHeight', () => { - const wake = ( - boundary: number, - opensUnix: number, - targetUnix: number, - ): RPCBroadcastWindowType => ({ - bucket_index: Math.floor(boundary / 144), - boundary, - part_ids: [], - denominations: [], - window_opens_unix_time: opensUnix, - latest_target_unix_time: targetUnix, - }); - - test('recovers the target offset the payload encoded at mainnet spacing', () => { - // zingolib put the target 100 blocks past the boundary. - const opens = 1_700_000_000; - const target = opens + 100 * TARGET_BLOCK_SPACING_SECONDS; - expect(windowTargetHeight(wake(3_428_352, opens, target))).toBe( - 3_428_352 + 100, - ); - }); - - test('a target at the boundary recovers as the boundary', () => { - const opens = 1_700_000_000; - expect(windowTargetHeight(wake(3_428_352, opens, opens))).toBe(3_428_352); - }); -}); - -describe('estimatedTimestampMs', () => { - const NOW = 1_700_000_000_000; - - test('scales block distance by the observed spacing', () => { - expect(estimatedTimestampMs(1_100, 1_000, 10, NOW)).toBe( - NOW + 100 * 10 * 1000, - ); - }); - - test('past heights estimate as now', () => { - expect(estimatedTimestampMs(900, 1_000, 10, NOW)).toBe(NOW); - }); -}); diff --git a/__tests__/IronwoodMigrationBanner.broadcastRun.unit.tsx b/__tests__/IronwoodMigrationBanner.broadcastRun.unit.tsx deleted file mode 100644 index b41233fa5..000000000 --- a/__tests__/IronwoodMigrationBanner.broadcastRun.unit.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/** - * @format - */ - -import 'react-native'; -import React from 'react'; - -import { render, waitFor } from '@testing-library/react-native'; -import IronwoodMigrationBanner from '../components/History/components/IronwoodMigrationBanner'; -import SegmentedBar from '../components/Migration/SegmentedBar'; -import { - defaultAppContextLoaded, - ContextAppLoadedProvider, -} from '../app/context'; -import { InfoType } from '../app/AppState'; -import { mockInfo } from '../__mocks__/dataMocks/mockInfo'; -import { mockTranslate } from '../__mocks__/dataMocks/mockTranslate'; -import { - migrationStatus, - reconcileMigration, -} from '../app/walletBackend'; -import { RPCMigrationStatusType } from '../app/walletBackend/types/RPCMigrationStatusType'; - -jest.mock('../app/walletBackend', () => ({ - migrationStatus: jest.fn(), - reconcileMigration: jest.fn(), -})); - -// The global mock stubs useFocusEffect to a no-op; the banner's refresh lives -// there, so this suite runs it as a plain effect, re-running when the callback -// identity changes (the banner keys it to the chain tip). -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('../__mocks__/@react-navigation/native.js'), - useFocusEffect: (cb: () => void | (() => void)) => { - require('react').useEffect(cb, [cb]); - }, -})); - -const migrationStatusMock = migrationStatus as jest.Mock; -const reconcileMock = reconcileMigration as jest.Mock; - -const baseStatus: RPCMigrationStatusType = { - orchard_confirmed_spendable: 40000000, - phase: { kind: 'parts_scheduled' }, - parts_total: 12, - parts_confirmed: 4, - parts_broadcast: 0, - value_total: 120000000, - value_migrated: 40000000, - per_bucket: 4, - bucket_modulus: 144, - upcoming_windows: [], - due_now: null, -}; - -// Batch of 4 broadcast and mining while the next window is already open: the -// backend reports due_now alongside parts_broadcast > 0. -const overlapStatus: RPCMigrationStatusType = { - ...baseStatus, - parts_broadcast: 4, - due_now: { - boundary: 3428608, - part_ids: [9, 10, 11, 12], - denominations: [10000000, 10000000, 10000000, 10000000], - }, -}; - -// The batch mined: broadcast run cleared into the confirmed figures. -const confirmedStatus: RPCMigrationStatusType = { - ...baseStatus, - parts_confirmed: 8, - value_migrated: 80000000, -}; - -function renderBanner(latestBlock: number) { - const state = { - ...defaultAppContextLoaded, - translate: mockTranslate, - info: { ...mockInfo, latestBlock } as InfoType, - }; - const banner = ( - - - - ); - return { ...render(banner), state }; -} - -describe('Ironwood banner broadcast run', () => { - beforeEach(() => { - reconcileMock.mockResolvedValue({ ok: true, value: '{}' }); - }); - - test('run stays lit while a new window opens over a mining batch', async () => { - migrationStatusMock.mockResolvedValue({ - ok: true, - value: JSON.stringify(overlapStatus), - }); - const { UNSAFE_getByType } = renderBanner(3428600); - await waitFor(() => { - expect(UNSAFE_getByType(SegmentedBar).props.active).toBe(4); - }); - expect(UNSAFE_getByType(SegmentedBar).props.activeSpan).toBe(4); - }); - - test('a new block clears the run into confirmed segments in place', async () => { - migrationStatusMock.mockResolvedValue({ - ok: true, - value: JSON.stringify(overlapStatus), - }); - const { UNSAFE_getByType, rerender } = renderBanner(3428600); - await waitFor(() => { - expect(UNSAFE_getByType(SegmentedBar).props.active).toBe(4); - }); - - migrationStatusMock.mockResolvedValue({ - ok: true, - value: JSON.stringify(confirmedStatus), - }); - rerender( - - - , - ); - - // The same mounted bar swaps its run for confirmed fill, which is the - // rising edge SegmentedBar's confirm flash fires on. - await waitFor(() => { - expect(UNSAFE_getByType(SegmentedBar).props.active).toBeUndefined(); - }); - expect(UNSAFE_getByType(SegmentedBar).props.progress).toBeCloseTo(8 / 12); - }); -}); diff --git a/__tests__/__snapshots__/History.snapshot.tsx.snap b/__tests__/__snapshots__/History.snapshot.tsx.snap index bbe6bbd16..b523c2a19 100644 --- a/__tests__/__snapshots__/History.snapshot.tsx.snap +++ b/__tests__/__snapshots__/History.snapshot.tsx.snap @@ -687,156 +687,143 @@ exports[`Component History - test History currency USD, privacy high & mode adva pointerEvents="box-none" > - - - text translated - - + /> text translated - - - 0.6000 - - - ZEC - - - - text translated - - - + 0.6000 + + + ZEC + + + + + text translated + @@ -1437,156 +1424,143 @@ exports[`Component History - test History no currency, privacy normal & mode bas pointerEvents="box-none" > - - - text translated - - + /> text translated - - - 0.6000 - - - ZEC - - - - text translated - - - + 0.6000 + + + ZEC + + + + + text translated + diff --git a/__tests__/walletBackend.ironwoodMigration.unit.test.ts b/__tests__/walletBackend.ironwoodMigration.unit.test.ts index 9218ec039..ede3e597b 100644 --- a/__tests__/walletBackend.ironwoodMigration.unit.test.ts +++ b/__tests__/walletBackend.ironwoodMigration.unit.test.ts @@ -1,5 +1,5 @@ /** - * The private-migration wrapper family (zingo-mobile#1187): all eleven + * The private-migration wrapper family (zingo-mobile#1187): all nine * walletUtils wrappers around the ZIP 318 bridge methods share one typed * FFI contract, so one table exercises them all: * - a resolution passes through verbatim as { ok: true }, even when it @@ -24,10 +24,7 @@ import { executeDuePartsStatus, migrationStatus, planIronwoodMigration, - quickSplit, reconcileMigration, - rescheduleParts, - splitStatus, startIronwoodMigration, } from '../app/walletBackend/utils/walletUtils'; @@ -51,19 +48,10 @@ const wrapperCases: WrapperCase[] = [ { wrapper: planIronwoodMigration, callArgs: [], expectedBridgeArgs: [] }, { wrapper: startIronwoodMigration, - // A null cadence crosses as the empty string ("keep zingolib's default"). - callArgs: [consentPlanHash, null], - expectedBridgeArgs: [consentPlanHash, ''], - }, - { - wrapper: startIronwoodMigration, - callArgs: [consentPlanHash, 4], - expectedBridgeArgs: [consentPlanHash, '4'], + callArgs: [consentPlanHash], + expectedBridgeArgs: [consentPlanHash], }, { wrapper: continueNoteSplitting, callArgs: [], expectedBridgeArgs: [] }, - { wrapper: quickSplit, callArgs: [], expectedBridgeArgs: [] }, - { wrapper: splitStatus, callArgs: [], expectedBridgeArgs: [] }, - { wrapper: rescheduleParts, callArgs: [8], expectedBridgeArgs: ['8'] }, { wrapper: migrationStatus, callArgs: [], expectedBridgeArgs: [] }, { wrapper: reconcileMigration, callArgs: [], expectedBridgeArgs: [] }, { wrapper: executeDueParts, callArgs: [2000], expectedBridgeArgs: ['2000'] }, diff --git a/__tests__/walletBackend.migrationRouting.unit.test.ts b/__tests__/walletBackend.migrationRouting.unit.test.ts index a76aa5d29..889a68c9f 100644 --- a/__tests__/walletBackend.migrationRouting.unit.test.ts +++ b/__tests__/walletBackend.migrationRouting.unit.test.ts @@ -1,16 +1,12 @@ /** * The consent screens' routing contract (zingo-mobile#1151): the special - * routes — resume an existing migration, replan on stale consent, review - * the standing schedule on a fixed cadence — are reachable only from - * typed rejection codes, never from error prose. A resolved payload - * carrying the legacy { error } JSON shape is a generic failure. + * routes — resume an existing migration, replan on stale consent — are + * reachable only from typed rejection codes, never from error prose. A + * resolved payload carrying the legacy { error } JSON shape is a generic + * failure. */ import { FfiResult } from '../app/walletBackend/ffi'; -import { - routeCadencePlan, - routeRescheduleParts, - routeStartMigration, -} from '../app/walletBackend/utils/migrationRouting'; +import { routeStartMigration } from '../app/walletBackend/utils/migrationRouting'; const rejected = (code: string, message = 'boom'): FfiResult => ({ ok: false, @@ -66,63 +62,3 @@ describe('routeStartMigration', () => { }); }); -describe('routeRescheduleParts', () => { - it('proceeds on a clean reschedule', () => { - expect(routeRescheduleParts({ ok: true, value: '{}' })).toEqual({ - kind: 'proceed', - }); - }); - - it('lets the standing schedule stand on a fixed cadence', () => { - expect(routeRescheduleParts(rejected('MigrationCadenceFixed'))).toEqual({ - kind: 'schedule-stands', - }); - }); - - it('surfaces any other rejection as an error with its message', () => { - expect( - routeRescheduleParts(rejected('MigrationNotInProgress', 'none')), - ).toEqual({ kind: 'error', message: 'none' }); - }); - - it('never routes on prose: a resolved body naming the cadence error is a generic failure', () => { - const legacy: FfiResult = { - ok: true, - value: '{"error":"cadence is fixed"}', - }; - expect(routeRescheduleParts(legacy)).toEqual({ - kind: 'error', - message: 'cadence is fixed', - }); - }); -}); - -describe('routeCadencePlan', () => { - it('offers the choice when the plan carries notes', () => { - expect(routeCadencePlan({ parts: [100, 100, 50], residual: 7 })).toEqual({ - kind: 'choose', - parts: 3, - }); - }); - - it('reports dust when nothing but residual is left', () => { - expect(routeCadencePlan({ parts: [], residual: 4200 })).toEqual({ - kind: 'dust', - residual: 4200, - }); - }); - - // The planner saw no notes at all, which after a split means its outputs - // are mined but not yet spendable at the anchor. - it('reports unconfirmed when the plan is empty of everything', () => { - expect( - routeCadencePlan({ split_rounds: [], parts: [], residual: 0 }), - ).toEqual({ kind: 'unconfirmed' }); - }); - - it('treats absent fields as an empty plan', () => { - expect(routeCadencePlan({ plan_hash: 'ab12' })).toEqual({ - kind: 'unconfirmed', - }); - }); -}); diff --git a/android/app/src/main/java/org/ZingoLabs/Zingo/RPCModule.kt b/android/app/src/main/java/org/ZingoLabs/Zingo/RPCModule.kt index d86b05674..3d209a15f 100644 --- a/android/app/src/main/java/org/ZingoLabs/Zingo/RPCModule.kt +++ b/android/app/src/main/java/org/ZingoLabs/Zingo/RPCModule.kt @@ -1017,17 +1017,11 @@ class RPCModule internal constructor(private val reactContext: ReactApplicationC } } - // `perBucket` crosses the bridge as a string (the module's numeric-arg - // convention); empty means "keep zingolib's default cadence", and a - // malformed value rejects as InvalidInput, matching the iOS bridge. @ReactMethod - fun startIronwoodMigrationProcess(planHashHex: String, perBucket: String, promise: Promise) { + fun startIronwoodMigrationProcess(planHashHex: String, promise: Promise) { FfiOutcome.settling(promise, "start_ironwood_migration") { uniffi.zingo.initLogging() - uniffi.zingo.startIronwoodMigration( - planHashHex, - FfiArgs.optionalU32(perBucket, "per_bucket") - ) + uniffi.zingo.startIronwoodMigration(planHashHex) } } @@ -1064,14 +1058,6 @@ class RPCModule internal constructor(private val reactContext: ReactApplicationC } } - @ReactMethod - fun reschedulePartsProcess(perBucket: String, promise: Promise) { - FfiOutcome.settling(promise, "reschedule_parts") { - uniffi.zingo.initLogging() - uniffi.zingo.rescheduleParts(FfiArgs.requiredU32(perBucket, "per_bucket")) - } - } - @ReactMethod fun migrationStatusProcess(promise: Promise) { FfiOutcome.settling(promise, "migration_status") { diff --git a/android/app/src/test/java/org/ZingoLabs/Zingo/FfiOutcomeTest.kt b/android/app/src/test/java/org/ZingoLabs/Zingo/FfiOutcomeTest.kt index d3cef5d25..f54c7440a 100644 --- a/android/app/src/test/java/org/ZingoLabs/Zingo/FfiOutcomeTest.kt +++ b/android/app/src/test/java/org/ZingoLabs/Zingo/FfiOutcomeTest.kt @@ -84,7 +84,6 @@ class FfiOutcomeTest { "plan_ironwood_migration" to (ZingolibException.Migration("boom") to "Migration"), "start_ironwood_migration" to (ZingolibException.MigrationConsentStale("boom") to "MigrationConsentStale"), "continue_note_splitting" to (ZingolibException.MigrationSplit("boom") to "MigrationSplit"), - "reschedule_parts" to (ZingolibException.MigrationCadenceFixed("boom") to "MigrationCadenceFixed"), "migration_status" to (ZingolibException.MigrationNotInProgress("boom") to "MigrationNotInProgress"), "reconcile_migration" to (ZingolibException.MigrationAlreadyInProgress("boom") to "MigrationAlreadyInProgress"), "execute_due_parts" to (ZingolibException.Offline("boom") to "Offline"), diff --git a/app/AppState/const/BlockTime.ts b/app/AppState/const/BlockTime.ts deleted file mode 100644 index bf916e90d..000000000 --- a/app/AppState/const/BlockTime.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { RPCBroadcastWindowType } from '../../walletBackend/types/RPCMigrationStatusType'; - -/** - * Mainnet's target block spacing, which is also what zingolib assumes when it - * extrapolates `window_opens_unix_time` and `latest_target_unix_time`. On a - * test rig ticking faster than mainnet those extrapolations read many times - * too long, so the app re-derives wall-clock estimates from block distances - * (which are exact) and the spacing it actually observes. - */ -export const TARGET_BLOCK_SPACING_SECONDS = 75; - -// One observation folds into the running estimate with this weight: heavy -// enough to converge within a handful of blocks after a rig change, light -// enough that a single odd gap doesn't swing the estimate. -const EMA_WEIGHT = 0.3; - -// Observations outside this range are measurement artifacts, not blocks: a -// staged height jump lands hundreds of blocks in one poll gap (seconds per -// block near zero), and a paused miner stretches one block over minutes. -const MIN_SPACING_SECONDS = 1; -const MAX_SPACING_SECONDS = 1200; - -/** - * Folds one observed seconds-per-block sample into the running estimate. - * `null` means no estimate yet. Artifact samples leave the estimate as it was. - */ -export const foldBlockSpacing = ( - estimate: number | null, - sample: number, -): number | null => { - if (sample < MIN_SPACING_SECONDS || sample > MAX_SPACING_SECONDS) { - return estimate; - } - if (estimate === null) { - return sample; - } - return estimate + (sample - estimate) * EMA_WEIGHT; -}; - -/** - * The window's advisory target as a block height. The payload carries it only - * as a unix estimate, but both unix fields come from the same fixed-spacing - * extrapolation, so their gap divided by that spacing recovers the target's - * offset from the boundary exactly. - */ -export const windowTargetHeight = (wake: RPCBroadcastWindowType): number => - wake.boundary + - Math.round( - (wake.latest_target_unix_time - wake.window_opens_unix_time) / - TARGET_BLOCK_SPACING_SECONDS, - ); - -/** - * When `targetHeight` is expected to be mined, in ms since epoch, extrapolated - * from the chain tip at the observed spacing. Past heights estimate as now, - * mirroring zingolib's `estimated_unix_at`. - */ -export const estimatedTimestampMs = ( - targetHeight: number, - currentHeight: number, - secondsPerBlock: number, - nowMs: number, -): number => - nowMs + Math.max(0, targetHeight - currentHeight) * secondsPerBlock * 1000; diff --git a/app/AppState/enums/RouteEnum.ts b/app/AppState/enums/RouteEnum.ts index 2842f5597..b0e8894c2 100644 --- a/app/AppState/enums/RouteEnum.ts +++ b/app/AppState/enums/RouteEnum.ts @@ -28,7 +28,6 @@ export enum RouteEnum { MigrationSending = 'MigrationSending', MigrationSplitPlan = 'MigrationSplitPlan', MigrationSplitting = 'MigrationSplitting', - MigrationCadence = 'MigrationCadence', MigrationSchedule = 'MigrationSchedule', MigrationStatus = 'MigrationStatus', MigrationBatchSending = 'MigrationBatchSending', diff --git a/app/AppState/index.js b/app/AppState/index.js index fda5ba710..ba9687881 100644 --- a/app/AppState/index.js +++ b/app/AppState/index.js @@ -58,12 +58,6 @@ import { BlockExplorerEnum } from './enums/BlockExplorerEnum'; import { GlobalConst } from './const/GlobalConst'; import { isIronwoodActive } from './const/IronwoodActivation'; -import { - TARGET_BLOCK_SPACING_SECONDS, - foldBlockSpacing, - windowTargetHeight, - estimatedTimestampMs, -} from './const/BlockTime'; import AppStateLoaded from './AppStateLoaded'; import AppStateLoading from './AppStateLoading'; @@ -132,8 +126,4 @@ export { BlockExplorerEnum, GlobalConst, isIronwoodActive, - TARGET_BLOCK_SPACING_SECONDS, - foldBlockSpacing, - windowTargetHeight, - estimatedTimestampMs, }; diff --git a/app/LoadedApp/LoadedApp.tsx b/app/LoadedApp/LoadedApp.tsx index bde96c3e7..acde18c0e 100644 --- a/app/LoadedApp/LoadedApp.tsx +++ b/app/LoadedApp/LoadedApp.tsx @@ -156,9 +156,6 @@ const MigrationSplitPlan = React.lazy( const MigrationSplitting = React.lazy( () => import('../../components/MigrationSplitting'), ); -const MigrationCadence = React.lazy( - () => import('../../components/MigrationCadence'), -); const MigrationSchedule = React.lazy( () => import('../../components/MigrationSchedule'), ); @@ -2628,11 +2625,6 @@ export class LoadedAppClass extends Component< // in-screen. options={{ gestureEnabled: false }} /> - ; // Ironwood private migration (ZIP 318 note splitting + scheduled parts). - // Numeric arguments cross the bridge as strings; empty perBucket keeps - // zingolib's default cadence. planIronwoodMigrationProcess(): Promise; - startIronwoodMigrationProcess( - planHashHex: string, - perBucket: string, - ): Promise; + startIronwoodMigrationProcess(planHashHex: string): Promise; continueNoteSplittingProcess(): Promise; - // Phase 1 splitting, stateless and send-shaped (ADR 0016). One call per - // round; loop until the outcome is `complete`, then startIronwoodMigration. - quickSplitProcess(): Promise; - // Live progress of the in-flight splitting round; safe to poll concurrently - // with quickSplitProcess (reads a native side channel, not the lightclient - // lock). - splitStatusProcess(): Promise; - reschedulePartsProcess(perBucket: string): Promise; migrationStatusProcess(): Promise; - // The window calendar (past, current, future) for a schedule grid, valid - // with or without a migration; null before the wallet has ever synced. - windowTimelineProcess(): Promise; reconcileMigrationProcess(): Promise; // Phase-2 execute tap: sends a scheduled window's due batch. spacingMs (the // delay sequenced between the batch's sends) crosses as a string. diff --git a/app/translations/en.json b/app/translations/en.json index 9cdc892cf..34733d30e 100644 --- a/app/translations/en.json +++ b/app/translations/en.json @@ -946,27 +946,6 @@ "retry": "Retry", "close": "Close" }, - "migrationcadence": { - "title": "Schedule transfers", - "intro": "Your **{notes} notes** will be sent to Ironwood in **batches**. The blockchain is divided into windows of **{blocks} blocks** (~{hours}h), each batch goes out in its own window.", - "how-many": "How many batches?", - "fewer-title": "Fewer, larger batches", - "fewer-body": "Done sooner. More notes visible together.", - "more-title": "More, smaller batches", - "more-body": "Takes longer. Better privacy.", - "duration-hours": "Finishes in about {n} hours", - "duration-days": "Finishes in about {n} days", - "disclosure": "Each batch sends when you open its reminder.", - "cadence-fixed": "Sending already began. The batch schedule can no longer change.", - "replanned": "Your notes changed. Review the updated schedule.", - "review": "Review schedule", - "dust-title": "Nothing left to migrate", - "dust-body": "The {amount} ZEC still in Orchard is below the amount worth moving. Fees would cost more than it carries.", - "unconfirmed-title": "Your notes aren't confirmed yet", - "unconfirmed-body": "The split transactions are on-chain, but the wallet can't spend their notes for another block or two. Try again in a few minutes.", - "back": "Back", - "retry": "Try again" - }, "migrationschedule": { "title": "Review & confirm", "intro": "Confirming sends the first batch now and schedules a **reminder for each later batch**. When a reminder arrives, open the app and send that batch.", diff --git a/app/translations/es.json b/app/translations/es.json index edf565706..fe8fd2c36 100644 --- a/app/translations/es.json +++ b/app/translations/es.json @@ -946,27 +946,6 @@ "retry": "Reintentar", "close": "Cerrar" }, - "migrationcadence": { - "title": "Programa las transferencias", - "intro": "Tus **{notes} notas** se enviarán a Ironwood en **lotes**. La blockchain se divide en ventanas de **{blocks} bloques** (~{hours}h); cada lote sale en su propia ventana.", - "how-many": "¿Cuántos lotes?", - "fewer-title": "Menos lotes, más grandes", - "fewer-body": "Termina antes. Más notas visibles juntas.", - "more-title": "Más lotes, más pequeños", - "more-body": "Tarda más. Mejor privacidad.", - "duration-hours": "Termina en unas {n} horas", - "duration-days": "Termina en unos {n} días", - "disclosure": "Cada lote se envía cuando abres su recordatorio.", - "cadence-fixed": "El envío ya comenzó. El calendario de lotes ya no puede cambiar.", - "replanned": "Tus notas cambiaron. Revisa el calendario actualizado.", - "review": "Revisar calendario", - "dust-title": "No queda nada por migrar", - "dust-body": "Los {amount} ZEC que quedan en Orchard están por debajo del importe que vale la pena mover. Las comisiones costarían más de lo que lleva.", - "unconfirmed-title": "Tus notas aún no están confirmadas", - "unconfirmed-body": "Las transacciones de división ya están en la cadena, pero la billetera no puede gastar sus notas hasta dentro de un bloque o dos. Inténtalo de nuevo en unos minutos.", - "back": "Atrás", - "retry": "Reintentar" - }, "migrationschedule": { "title": "Revisar y confirmar", "intro": "Confirmar envía el primer lote ahora y programa un **recordatorio por cada lote posterior**. Cuando llegue un recordatorio, abre la app y envía ese lote.", diff --git a/app/translations/pt.json b/app/translations/pt.json index d02eba1b9..a46884f06 100644 --- a/app/translations/pt.json +++ b/app/translations/pt.json @@ -946,27 +946,6 @@ "retry": "Tentar de novo", "close": "Fechar" }, - "migrationcadence": { - "title": "Agende as transferências", - "intro": "Suas **{notes} notas** serão enviadas para a Ironwood em **lotes**. A blockchain é dividida em janelas de **{blocks} blocos** (~{hours}h); cada lote sai na sua própria janela.", - "how-many": "Quantos lotes?", - "fewer-title": "Menos lotes, maiores", - "fewer-body": "Termina antes. Mais notas visíveis juntas.", - "more-title": "Mais lotes, menores", - "more-body": "Demora mais. Melhor privacidade.", - "duration-hours": "Termina em cerca de {n} horas", - "duration-days": "Termina em cerca de {n} dias", - "disclosure": "Cada lote é enviado quando você abre o lembrete dele.", - "cadence-fixed": "O envio já começou. O cronograma dos lotes não pode mais mudar.", - "replanned": "Suas notas mudaram. Revise o cronograma atualizado.", - "review": "Revisar cronograma", - "dust-title": "Não resta nada para migrar", - "dust-body": "Os {amount} ZEC que restam na Orchard estão abaixo do valor que vale a pena mover. As taxas custariam mais do que ele carrega.", - "unconfirmed-title": "Suas notas ainda não estão confirmadas", - "unconfirmed-body": "As transações de divisão já estão na cadeia, mas a carteira não pode gastar as notas delas por mais um bloco ou dois. Tente de novo em alguns minutos.", - "back": "Voltar", - "retry": "Tentar de novo" - }, "migrationschedule": { "title": "Revisar e confirmar", "intro": "Confirmar envia o primeiro lote agora e agenda um **lembrete para cada lote seguinte**. Quando um lembrete chegar, abra o app e envie aquele lote.", diff --git a/app/translations/ru.json b/app/translations/ru.json index 758f51032..8708e042a 100644 --- a/app/translations/ru.json +++ b/app/translations/ru.json @@ -946,27 +946,6 @@ "retry": "Повторить", "close": "Закрыть" }, - "migrationcadence": { - "title": "Запланируйте переводы", - "intro": "Ваши **ноты ({notes} шт.)** будут отправлены в Ironwood **партиями**. Блокчейн делится на окна по **{blocks} блоков** (~{hours}ч); каждая партия уходит в своём окне.", - "how-many": "Сколько партий?", - "fewer-title": "Меньше партий, крупнее", - "fewer-body": "Быстрее закончится. Больше нот видно вместе.", - "more-title": "Больше партий, мельче", - "more-body": "Дольше. Лучше приватность.", - "duration-hours": "Завершится примерно за {n} ч", - "duration-days": "Завершится примерно за {n} дн", - "disclosure": "Каждая партия отправляется, когда вы открываете её напоминание.", - "cadence-fixed": "Отправка уже началась. Расписание партий больше нельзя изменить.", - "replanned": "Ваши ноты изменились. Проверьте обновлённое расписание.", - "review": "Просмотреть расписание", - "dust-title": "Мигрировать больше нечего", - "dust-body": "Оставшиеся в Orchard {amount} ZEC меньше суммы, которую стоит перемещать. Комиссии обойдутся дороже, чем сами средства.", - "unconfirmed-title": "Ваши ноты ещё не подтверждены", - "unconfirmed-body": "Транзакции разделения уже в сети, но кошелёк сможет потратить их ноты только через блок-другой. Повторите попытку через несколько минут.", - "back": "Назад", - "retry": "Повторить" - }, "migrationschedule": { "title": "Проверьте и подтвердите", "intro": "Подтверждение отправляет первую партию сейчас и создаёт **напоминание для каждой следующей партии**. Когда напоминание придёт, откройте приложение и отправьте эту партию.", diff --git a/app/translations/tr.json b/app/translations/tr.json index d66c1c072..918ef8e06 100644 --- a/app/translations/tr.json +++ b/app/translations/tr.json @@ -946,27 +946,6 @@ "retry": "Yeniden dene", "close": "Kapat" }, - "migrationcadence": { - "title": "Aktarımları planlayın", - "intro": "**{notes} notunuz** Ironwood’a **gruplar** halinde gönderilecek. Blok zinciri **{blocks} bloklu** pencerelere bölünür (~{hours}sa), her grup kendi penceresinde gönderilir.", - "how-many": "Kaç grup?", - "fewer-title": "Daha az, daha büyük gruplar", - "fewer-body": "Daha erken biter. Birlikte görünen not sayısı artar.", - "more-title": "Daha çok, daha küçük gruplar", - "more-body": "Daha uzun sürer. Daha iyi gizlilik.", - "duration-hours": "Yaklaşık {n} saatte biter", - "duration-days": "Yaklaşık {n} günde biter", - "disclosure": "Her grup, hatırlatıcısını açtığınızda gönderilir.", - "cadence-fixed": "Gönderim zaten başladı. Grup takvimi artık değiştirilemez.", - "replanned": "Notlarınız değişti. Güncellenen takvimi gözden geçirin.", - "review": "Takvimi gözden geçir", - "dust-title": "Taşınacak bir şey kalmadı", - "dust-body": "Orchard’da kalan {amount} ZEC, taşımaya değer tutarın altında. Ücretler taşıdığından fazlasına mal olur.", - "unconfirmed-title": "Notlarınız henüz onaylanmadı", - "unconfirmed-body": "Bölme işlemleri zincirde, ancak cüzdan bir iki blok daha bu notları harcayamaz. Birkaç dakika sonra tekrar deneyin.", - "back": "Geri", - "retry": "Tekrar dene" - }, "migrationschedule": { "title": "Gözden geçir ve onayla", "intro": "Onaylamak ilk grubu şimdi gönderir ve **sonraki her grup için bir hatırlatıcı** planlar. Hatırlatıcı geldiğinde uygulamayı açın ve o grubu gönderin.", diff --git a/app/types/NavigationTypes.ts b/app/types/NavigationTypes.ts index 40fea2205..0828a6225 100644 --- a/app/types/NavigationTypes.ts +++ b/app/types/NavigationTypes.ts @@ -83,10 +83,7 @@ export type AppDrawerParamList = { // transaction rows match what the user accepted. Absent on banner-rescue // re-entry, where the screen renders coarsely from migrationStatus. [RouteEnum.MigrationSplitting]: { plan?: RPCMigrationPlanType } | undefined; - [RouteEnum.MigrationCadence]: undefined; - // The cadence the user picked, so Back from the review screen can restore - // the selection. - [RouteEnum.MigrationSchedule]: { perBucket: number }; + [RouteEnum.MigrationSchedule]: undefined; // The in-flight "Migration underway" monitor: the landing after the schedule // is confirmed and the parts_scheduled banner's resume target. Reads // migrationStatus, so it needs no params. diff --git a/app/walletBackend/ffi.ts b/app/walletBackend/ffi.ts index 998217267..1cc994e1c 100644 --- a/app/walletBackend/ffi.ts +++ b/app/walletBackend/ffi.ts @@ -27,7 +27,6 @@ const FFI_ERROR_CODES = [ 'MigrationNotInProgress', 'MigrationAlreadyInProgress', 'MigrationConsentStale', - 'MigrationCadenceFixed', 'MigrationSplit', 'Migration', 'Mixnet', diff --git a/app/walletBackend/index.ts b/app/walletBackend/index.ts index fde4f5a9c..2d22503ed 100644 --- a/app/walletBackend/index.ts +++ b/app/walletBackend/index.ts @@ -8,16 +8,8 @@ import WalletBackend from './WalletBackend'; export type { FfiError, FfiErrorCode, FfiResult } from './ffi'; -export type { - CadencePlanRoute, - ReschedulePartsRoute, - StartMigrationRoute, -} from './utils/migrationRouting'; -export { - routeCadencePlan, - routeRescheduleParts, - routeStartMigration, -} from './utils/migrationRouting'; +export type { StartMigrationRoute } from './utils/migrationRouting'; +export { routeStartMigration } from './utils/migrationRouting'; export { scanInProgress } from './utils/syncProgress'; export { cancelIronwoodMigration, @@ -52,10 +44,8 @@ export { parseAddress, planIronwoodMigration, planOrchardDrain, - quickSplit, reconcileMigration, removeTransaction, - rescheduleParts, resolvedTrue, restoreExistingWalletBackup, restoreWalletFromSeed, @@ -65,10 +55,8 @@ export { setCryptoDefaultProvider, shieldConfirm, shieldPropose, - splitStatus, startIronwoodMigration, walletBackupExists, walletExists, - windowTimeline, } from './utils/walletUtils'; export default WalletBackend; diff --git a/app/walletBackend/modules/DataService.ts b/app/walletBackend/modules/DataService.ts index 2a4763150..7a42b29f6 100644 --- a/app/walletBackend/modules/DataService.ts +++ b/app/walletBackend/modules/DataService.ts @@ -20,7 +20,6 @@ import { ValueTransferType, UnifiedAddressClass, TransparentAddressClass, - foldBlockSpacing, } from '../../AppState'; import RPCModule from '../../RPCModule'; import { RPCUnifiedAddressType } from '../types/RPCUnifiedAddressType'; @@ -46,13 +45,6 @@ export class DataService { lastServerBlockHeight: number = 0; walletBirthday: number = 0; - // Block-spacing observation: the last (height, wall-clock) pair a server - // reading moved from, and the EMA the samples fold into. Null until two - // readings at different heights land. - blockTimeBaseHeight: number = 0; - blockTimeBaseMs: number = 0; - secondsPerBlock: number | null = null; - fetchWalletHeightLock: boolean = false; fetchWalletBirthdaySeedUfvkLock: boolean = false; fetchInfoAndServerHeightLock: boolean = false; @@ -295,11 +287,6 @@ export class DataService { ironwoodActivationHeight: infoJSON.ironwood_activation_height ?? null, }; - this.observeBlockSpacing(info.latestBlock, Date.now()); - if (this.secondsPerBlock !== null) { - info.secondsPerBlock = this.secondsPerBlock; - } - this.config.onInfoChanged(info); this.lastServerBlockHeight = info.latestBlock; } catch (error) { @@ -311,32 +298,6 @@ export class DataService { } } - // One sample per height change: the wall-clock gap since the last reading - // that moved, divided by how many blocks it moved. A rewound tip (server - // restart) just re-bases; foldBlockSpacing rejects artifact samples (staged - // jumps, paused miners) but the base advances regardless, so one artifact - // never pollutes the next sample. - private observeBlockSpacing(height: number, nowMs: number): void { - if (height <= 0) { - return; - } - if (this.blockTimeBaseHeight === 0 || height < this.blockTimeBaseHeight) { - this.blockTimeBaseHeight = height; - this.blockTimeBaseMs = nowMs; - return; - } - if (height === this.blockTimeBaseHeight) { - return; - } - const sample = - (nowMs - this.blockTimeBaseMs) / - 1000 / - (height - this.blockTimeBaseHeight); - this.blockTimeBaseHeight = height; - this.blockTimeBaseMs = nowMs; - this.secondsPerBlock = foldBlockSpacing(this.secondsPerBlock, sample); - } - async fetchZingolibVersion(): Promise { if (this.fetchZingolibVersionLock) { return; diff --git a/app/walletBackend/types/RPCBatchReportType.ts b/app/walletBackend/types/RPCBatchReportType.ts index 0aee2cfe6..1b78d8c20 100644 --- a/app/walletBackend/types/RPCBatchReportType.ts +++ b/app/walletBackend/types/RPCBatchReportType.ts @@ -6,7 +6,7 @@ // sent -> accepted by the broadcast endpoint (`txid`). // slid -> its window boundary is no longer witnessable; reconciliation // carries it to a coming window, nothing is lost. -// not_due -> its random target is still ahead (`window_opens_unix_time`). +// not_due -> its window has not opened yet (`window_opens_unix_time`). // failed -> submission failed and the batch halted here. // // `halted` is the error that stopped the batch early (null when it ran to diff --git a/app/walletBackend/types/RPCMigrationStatusType.ts b/app/walletBackend/types/RPCMigrationStatusType.ts index 59446d138..05d969492 100644 --- a/app/walletBackend/types/RPCMigrationStatusType.ts +++ b/app/walletBackend/types/RPCMigrationStatusType.ts @@ -14,11 +14,10 @@ export type RPCMigrationPhaseType = { }; // One coming broadcast window. Two wakes per window: a silent sync near -// `window_opens_unix_time` (the boundary, where proof material is captured, and -// from which the window's parts are already due) and the user-facing reminder -// at `latest_target_unix_time` — advisory only, a hint for when to nudge -// the user so sends disperse across the window, no longer a gate on sending. -export type RPCBroadcastWindowType = { +// `window_opens_unix_time` (the boundary, where proof material is captured) +// and the user-facing reminder at `latest_target_unix_time` (when every part +// of the window is due). +export type RPCWakePointType = { bucket_index: number; // The window's opening boundary, also the parts' anchor height. boundary: number; @@ -32,10 +31,10 @@ export type RPCBroadcastWindowType = { }; // The batch the user can broadcast right now: the window the chain is -// currently inside, plus any overdue parts folded in. `upcoming_windows` -// carries only future windows and structurally cannot hold this one, so the -// "send batch" action reads it from here. `denominations` align -// element-for-element with `part_ids`, both in the window's broadcast order. +// currently inside, plus any overdue parts folded in. `upcoming_windows` carries +// only future windows and structurally cannot hold this one, so the "send +// batch" action reads it from here. `denominations` align element-for-element +// with `part_ids`, both in the window's broadcast order. export type RPCDueBatchType = { // The current bucket's opening boundary (the parts' anchor height). boundary: number; @@ -59,15 +58,12 @@ export type RPCMigrationStatusType = { parts_broadcast: number; value_total: number; value_migrated: number; - // The effective cadence (parts per window); null when no migration exists. - per_bucket: number | null; // Window length in blocks (144 provisionally). bucket_modulus: number; - upcoming_windows: RPCBroadcastWindowType[]; - // The batch broadcastable this instant (the window the chain is inside), - // populated for the whole open window from its boundary onward, or null when - // a send now would build nothing (no migration, wrong phase, the window not - // yet open, or all parts confirmed). + upcoming_windows: RPCWakePointType[]; + // The batch broadcastable this instant (the window the chain is inside), or + // null when a send now would build nothing (no migration, wrong phase, every + // part still ahead of its random target, or all parts confirmed). due_now: RPCDueBatchType | null; error?: string; }; diff --git a/app/walletBackend/types/RPCSplitOutcomeType.ts b/app/walletBackend/types/RPCSplitOutcomeType.ts deleted file mode 100644 index 57097ca0e..000000000 --- a/app/walletBackend/types/RPCSplitOutcomeType.ts +++ /dev/null @@ -1,13 +0,0 @@ -// What one `quick_split` call did (native `quickSplitProcess`), ADR 0016. The -// stateless splitting loop matches on `outcome`: sync between calls and keep -// going until `complete`, then run startIronwoodMigration for Phase 2. -export type RPCSplitOutcomeKind = - 'round' | 'awaiting_confirmation' | 'complete'; - -export type RPCSplitOutcomeType = { - outcome?: RPCSplitOutcomeKind; - // round: the round just built and broadcast. Sync until they confirm, then - // call again. - txids?: string[]; - error?: string; -}; diff --git a/app/walletBackend/types/RPCSplitStatusType.ts b/app/walletBackend/types/RPCSplitStatusType.ts deleted file mode 100644 index 2e6f6a96c..000000000 --- a/app/walletBackend/types/RPCSplitStatusType.ts +++ /dev/null @@ -1,17 +0,0 @@ -// A snapshot of the in-flight Phase 1 splitting round, as returned by zingolib's -// `split_status` (native `splitStatusProcess`). `null` from the native call -// means no round is running (before it starts, or once it has finished); -// otherwise the counts are 0..=total and advance as the round builds then -// broadcasts. The Phase 1 mirror of RPCDrainStatusType. -export type RPCSplitStatusPhase = 'building' | 'transmitting'; - -export type RPCSplitStatusType = { - // Transactions in this round (N), fixed for the round. - total: number; - // Transactions proved + signed so far, 0..=total. - built: number; - // Transactions broadcast so far, 0..=total. - sent: number; - // Which phase the round is in: proving/signing, then broadcasting. - phase: RPCSplitStatusPhase; -}; diff --git a/app/walletBackend/types/RPCWindowReportType.ts b/app/walletBackend/types/RPCWindowReportType.ts deleted file mode 100644 index 5d3943e6d..000000000 --- a/app/walletBackend/types/RPCWindowReportType.ts +++ /dev/null @@ -1,26 +0,0 @@ -// One row of the window calendar returned by zingolib's `window_timeline` -// (native `windowTimelineProcess`), for a schedule screen's grid. Heights are -// in blocks, values in zatoshis. The whole call returns null (RPCWindowTimeline -// below) before the wallet has ever synced. -export type RPCWindowReportType = { - bucket_index: number; - // The window's opening boundary (inclusive), also the parts' anchor height. - boundary: number; - // The window's closing height (exclusive): the next boundary. - close: number; - // Whether the chain tip is inside this window ("you are here"). - is_current: boolean; - // Parts assigned to this window. - parts_total: number; - // Parts of this window confirmed into Ironwood. - parts_confirmed: number; - // Total value assigned to this window. - value_total: number; - // Value confirmed into Ironwood from this window. - value_migrated: number; -}; - -// The full timeline, earliest first, or null when the wallet has never synced. -// Exists with or without a migration: the window the tip sits in is always -// present (zero tallies when nothing is scheduled there). -export type RPCWindowTimelineType = RPCWindowReportType[] | null; diff --git a/app/walletBackend/utils/migrationRouting.ts b/app/walletBackend/utils/migrationRouting.ts index f921cd18a..3c020dae6 100644 --- a/app/walletBackend/utils/migrationRouting.ts +++ b/app/walletBackend/utils/migrationRouting.ts @@ -1,5 +1,4 @@ import { FfiResult } from '../ffi'; -import { RPCMigrationPlanType } from '../types/RPCMigrationPlanType'; /** * Pure routing of the migration consent screens' FFI outcomes @@ -43,51 +42,3 @@ export function routeStartMigration( return { kind: 'proceed' }; } -export type CadencePlanRoute = - | { kind: 'choose'; parts: number } - | { kind: 'dust'; residual: number } - | { kind: 'unconfirmed' }; - -// Routes the post-split plan at the cadence screen, which must never consent -// to a plan carrying no notes: start_ironwood_migration would bind a migration -// with zero batches and leave no way forward. Zero notes with a residual means -// every note sits below the sweep floor, so waiting changes nothing. Zero with -// nothing at all means the planner saw no notes: the split's outputs are mined -// but not yet spendable at the anchor, and the real count arrives a couple of -// blocks later. -export function routeCadencePlan(plan: RPCMigrationPlanType): CadencePlanRoute { - const parts = plan.parts?.length ?? 0; - if (parts > 0) { - return { kind: 'choose', parts }; - } - const residual = plan.residual ?? 0; - return residual > 0 ? { kind: 'dust', residual } : { kind: 'unconfirmed' }; -} - -export type ReschedulePartsRoute = - | { kind: 'proceed' } - | { kind: 'schedule-stands' } - | { kind: 'error'; message: string }; - -// Routes reschedule_parts at the cadence screen: CadenceFixed means a part -// is already signed, so the existing schedule stands and reviewing it is -// still valid; anything else is an error. -export function routeRescheduleParts( - reschedule: FfiResult, -): ReschedulePartsRoute { - if (!reschedule.ok) { - if (reschedule.error.code === 'MigrationCadenceFixed') { - return { kind: 'schedule-stands' }; - } - return { kind: 'error', message: reschedule.error.message }; - } - try { - const parsed = JSON.parse(reschedule.value); - if (parsed.error) { - return { kind: 'error', message: String(parsed.error) }; - } - } catch (e) { - return { kind: 'error', message: `${e}` }; - } - return { kind: 'proceed' }; -} diff --git a/app/walletBackend/utils/walletUtils.ts b/app/walletBackend/utils/walletUtils.ts index 7afc143d8..7f7166c4c 100644 --- a/app/walletBackend/utils/walletUtils.ts +++ b/app/walletBackend/utils/walletUtils.ts @@ -315,24 +315,16 @@ export async function planIronwoodMigration(): Promise> { return callFfi(RPCModule.planIronwoodMigrationProcess()); } -// Phase 2 of the private migration. Called once quickSplit reports `complete` -// (ADR 0016): the notes are fully split by then, so this binds the parts to -// their notes and schedules them at once. Consent is captured post-split, so -// `planHashHex` is the hash of a fresh planIronwoodMigration read taken here, -// not the pre-split one. `perBucket` null keeps zingolib's default cadence -// (changeable later via rescheduleParts, until the first part is signed). The -// success value is `{ started: true }` JSON; a stale consent rejects with code -// MigrationConsentStale. +// Records the user's consent to the exact plan they were shown (its +// `plan_hash` from planIronwoodMigration) and persists the migration state. +// Nothing is broadcast; continueNoteSplitting drives the rounds afterwards. +// The broadcast cadence is not a parameter: the ZIP 318 schedule draws every +// delay itself. The success value is `{ started: true }` JSON; a stale +// consent rejects with code MigrationConsentStale. export async function startIronwoodMigration( planHashHex: string, - perBucket: number | null, ): Promise> { - return callFfi( - RPCModule.startIronwoodMigrationProcess( - planHashHex, - perBucket === null ? '' : String(perBucket), - ), - ); + return callFfi(RPCModule.startIronwoodMigrationProcess(planHashHex)); } // Drives one step of note splitting: proves and broadcasts the next round of @@ -344,38 +336,6 @@ export async function continueNoteSplitting(): Promise> { return callFfi(RPCModule.continueNoteSplittingProcess()); } -// Phase 1 note splitting, the send-shaped replacement for the stateful -// startIronwoodMigration + continueNoteSplitting driver (ADR 0016). One call -// does one round: it pauses sync, plans against current notes, builds and -// broadcasts the round, and persists no migration state. Long-running like -// drainOrchard (Halo2 proving), dispatched on the concurrent pool. Loop it — -// sync to confirmation between calls — until the outcome is `complete`, then -// call startIronwoodMigration for Phase 2. The success value is raw JSON -// (parseable as RPCSplitOutcomeType). -export async function quickSplit(): Promise> { - return callFfi(RPCModule.quickSplitProcess()); -} - -// Snapshot of the in-flight splitting round's progress, for rendering -// "built i/N" then "sent i/N". Mirrors the native `splitStatusProcess`; safe to -// poll concurrently with a running `quickSplit` (native reads a side channel, -// not the lightclient lock). The success value is raw JSON: `null` when no round -// is running, otherwise `{ total, built, sent, phase }` (parseable as -// RPCSplitStatusType). -export async function splitStatus(): Promise> { - return callFfi(RPCModule.splitStatusProcess()); -} - -// Sets the Phase 2 cadence (parts per broadcast window) and re-buckets every -// part with fresh randomization. Callable any time between consent and the -// first signed part; afterwards rejects with code MigrationCadenceFixed. -// After success the old schedule is void: re-read migrationStatus and re-arm -// the reminders. The success value is `{ rescheduled: true }` JSON. -export async function rescheduleParts( - perBucket: number, -): Promise> { - return callFfi(RPCModule.reschedulePartsProcess(String(perBucket))); -} // The private migration's progress, arranged for direct rendering (parseable // as RPCMigrationStatusType). `phase` is null when no migration is in @@ -385,15 +345,6 @@ export async function migrationStatus(): Promise> { return callFfi(RPCModule.migrationStatusProcess()); } -// The window calendar for a schedule grid (parseable as RPCWindowTimelineType): -// every scheduled window past and future plus always the window the tip sits -// in, so "you are here" and the grid render even before consent. The success -// value is raw JSON `null` when the wallet has never synced, otherwise an array -// of window reports. Offline-safe; never syncs. -export async function windowTimeline(): Promise> { - return callFfi(RPCModule.windowTimelineProcess()); -} - // Classifies every part against the local chain view, applies what is safe // unattended and returns what needs the app (parseable as RPCReconcileType). // Call on every launch; never syncs, offline-safe. diff --git a/components/History/components/IronwoodMigrationBanner.tsx b/components/History/components/IronwoodMigrationBanner.tsx index f7f220083..7f6bbbf0d 100644 --- a/components/History/components/IronwoodMigrationBanner.tsx +++ b/components/History/components/IronwoodMigrationBanner.tsx @@ -1,140 +1,20 @@ /* eslint-disable react-native/no-inline-styles */ import React, { useCallback, useContext, useState } from 'react'; -import { Pressable, Text, View, ViewStyle } from 'react-native'; -import Animated, { - Easing, - FadeIn, - FadeOut, - LinearTransition, - ReduceMotion, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; +import { Text, TouchableOpacity, View } from 'react-native'; import { useFocusEffect, useTheme } from '@react-navigation/native'; -import SegmentedBar from '../../Migration/SegmentedBar'; import { ContextAppLoaded } from '../../../app/context'; import { ThemeType } from '../../../app/types'; import Utils from '../../../app/utils'; import { RouteEnum } from '../../../app/AppState'; -import { - migrationStatus, - reconcileMigration, -} from '../../../app/walletBackend'; +import { migrationStatus } from '../../../app/walletBackend'; import { RPCMigrationStatusType, - RPCBroadcastWindowType, + RPCWakePointType, } from '../../../app/walletBackend/types/RPCMigrationStatusType'; const ZATS_PER_ZEC = 10 ** 8; -const PRESS_SCALE = 0.97; -const PRESS_MS = 160; -const STATUS_FADE_MS = 200; - -// Colour and opacity are what reduced motion keeps, so the fades run either -// way. The height change between the two variants is movement, and does not. -const bannerLayout = () => - LinearTransition.duration(260).reduceMotion(ReduceMotion.System); -const bannerExit = () => FadeOut.duration(140).reduceMotion(ReduceMotion.Never); -const variantEnter = () => - FadeIn.duration(200).reduceMotion(ReduceMotion.Never); - -// Press feedback for the two cards. Scale reads as a push on a target this -// large, where a wash of opacity reads as the card dimming for its own reasons. -const PressScale: React.FunctionComponent<{ - testID: string; - onPress: () => void; - style: ViewStyle; - children: React.ReactNode; -}> = ({ testID, onPress, style, children }) => { - const pressed = useSharedValue(0); - const press = useAnimatedStyle(() => ({ - transform: [ - { - scale: withTiming(1 - pressed.value * (1 - PRESS_SCALE), { - duration: PRESS_MS, - easing: Easing.out(Easing.cubic), - reduceMotion: ReduceMotion.System, - }), - }, - ], - })); - return ( - { - pressed.value = 1; - }} - onPressOut={() => { - pressed.value = 0; - }} - > - {children} - - ); -}; - -// The dot and the pill are the only signal that the phase moved on, and the -// phase moves a handful of times across a whole migration. Cutting the colour -// reads as a re-render; fading it reads as the state changing. -const StatusDot: React.FunctionComponent<{ color: string; size: number }> = ({ - color, - size, -}) => { - const tint = useAnimatedStyle(() => ({ - backgroundColor: withTiming(color, { - duration: STATUS_FADE_MS, - reduceMotion: ReduceMotion.Never, - }), - })); - return ( - - ); -}; - -const StatusPill: React.FunctionComponent<{ color: string; label: string }> = ({ - color, - label, -}) => { - const tint = useAnimatedStyle(() => ({ - backgroundColor: withTiming(`${color}22`, { - duration: STATUS_FADE_MS, - reduceMotion: ReduceMotion.Never, - }), - })); - const ink = useAnimatedStyle(() => ({ - color: withTiming(color, { - duration: STATUS_FADE_MS, - reduceMotion: ReduceMotion.Never, - }), - })); - return ( - - - {label} - - - ); -}; - type IronwoodMigrationBannerProps = { // Orchard balance still sitting in the pool, in ZEC. amount: number; @@ -170,28 +50,12 @@ const IronwoodMigrationBanner: React.FunctionComponent< null, ); - // A window opens at a block boundary, so `due_now` flips while the user sits - // on History with nothing to re-trigger a read. Keying the refresh to the - // chain tip is what catches it: focus alone left the countdown at zero and - // the pill on Pending until the user navigated away and back. - const height = info?.latestBlock ?? 0; - - // Re-check on focus and on every new block: the phase advances while the - // user is elsewhere in the flow, and this banner is what routes them back - // correctly. + // Re-check on every focus: the phase advances while the user is elsewhere + // in the flow, and this banner is what routes them back correctly. useFocusEffect( useCallback(() => { let cancelled = false; (async () => { - // Apply confirmations that landed since the last pass before reading. - // migration_status reports persisted part state, and reconcile is what - // promotes a mined batch out of Broadcast. Launch is the only other - // place it runs, so without this a batch that confirms mid-session - // reads as unsent until relaunch. Offline, a no-op with no migration. - await reconcileMigration(); - if (cancelled) { - return; - } const statusResult = await migrationStatus(); if (cancelled) { return; @@ -213,10 +77,7 @@ const IronwoodMigrationBanner: React.FunctionComponent< return () => { cancelled = true; }; - // `height` is a trigger, not a value the body reads: a new block is the - // only thing that can open a window while this screen stays mounted. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [height]), + }, []), ); const phaseKind = migration?.phase?.kind; @@ -228,26 +89,25 @@ const IronwoodMigrationBanner: React.FunctionComponent< // ----- In-flight variant ----- if (inFlight && migration) { const splitting = phaseKind === 'note_splitting'; - const scheduled = phaseKind === 'parts_scheduled'; - const resumeRoute = scheduled - ? RouteEnum.MigrationStatus - : RouteEnum.MigrationSplitting; + const resumeRoute = + phaseKind === 'parts_scheduled' + ? RouteEnum.MigrationStatus + : RouteEnum.MigrationSplitting; - // The bar counts notes, one segment each. Batches would be the coarser - // unit, but a cadence that fits the whole plan into one window leaves a - // single undivided block, and before the cadence is chosen per_bucket - // carries zingolib's provisional k_max of 8 rather than anything the user - // picked. parts_total is projected from the plan through Phase 1 and is the - // bound count afterwards, so the segments hold their meaning throughout. - const notesTotal = Math.max(1, migration.parts_total); - const notesConfirmed = Math.min(notesTotal, migration.parts_confirmed); - const pct = Math.round((notesConfirmed / notesTotal) * 100); + // The ZIP 318 schedule draws each part its own window, so progress counts + // parts directly, mirroring the MigrationStatus screen. + const batchesTotal = Math.max(1, migration.parts_total); + const batchesConfirmed = Math.min( + batchesTotal, + migration.parts_confirmed, + ); - // Batch numbering for the next-action line only. A batch counts as - // confirmed once all its notes do (floor division), as on the status - // screen, so "Batch 3" means the same in both places. - const perBucket = scheduled ? Math.max(1, migration.per_bucket ?? 1) : 1; - const batchesConfirmed = Math.floor(migration.parts_confirmed / perBucket); + // Value-migrated drives the bar, matching the MigrationStatus screen's + // canonical progress figure. + const pct = + migration.value_total > 0 + ? Math.round((migration.value_migrated / migration.value_total) * 100) + : 0; const orchardLeftStr = `${Utils.parseNumberFloatToStringLocale( migration.orchard_confirmed_spendable / ZATS_PER_ZEC, @@ -255,44 +115,36 @@ const IronwoodMigrationBanner: React.FunctionComponent< )} ${currencyName}`; // A batch is ready to send exactly when the backend reports a due batch: - // the window the chain is currently inside, which upcoming_windows cannot - // carry. upcoming_windows stays the source for the "waiting N blocks" - // countdown to the next scheduled window. - const wakes: RPCBroadcastWindowType[] = migration.upcoming_windows ?? []; + // the window the chain is currently inside, which next_wakes cannot carry. + // next_wakes stays the source for the "waiting N blocks" countdown to the + // next scheduled window. + const height = info?.latestBlock ?? 0; + const wakes: RPCWakePointType[] = migration.upcoming_windows ?? []; const nextWake = wakes[0]; const blocksUntil = nextWake ? Math.max(0, nextWake.boundary - height) : 0; const ready = migration.due_now != null; - // A batch broadcast but not yet mined: sent, confirming on-chain. Its window - // is gone from both due_now and upcoming_windows, so without this signal it would - // read as the next batch still pending. - const confirming = !splitting && !ready && migration.parts_broadcast > 0; - // The bar's broadcast run outlives `confirming`: a new window can open - // while the batch still mines, which flips the pill to Ready but changes - // nothing about the parts in flight. Keyed to parts_broadcast alone, the - // run stays lit until those parts confirm, and the confirm flash lands on - // lit segments instead of blanks. - const broadcasting = migration.parts_broadcast > 0; // Status pill (dot + word): Splitting while notes split, Ready when a batch - // can be sent now, Confirming while a sent batch mines, Pending while the - // next window is still ahead. + // can be sent now, Pending while the next window is still ahead. const statusKind = splitting ? 'splitting' : ready ? 'ready' - : confirming - ? 'confirming' - : 'pending'; + : 'pending'; const statusColor = statusKind === 'ready' ? colors.primary - : statusKind === 'splitting' || statusKind === 'confirming' + : statusKind === 'splitting' ? colors.syncing : colors.warning.primary; const statusLabel = translate( `ironwoodbanner.status-${statusKind}`, ) as string; + const batchesValue = (translate('ironwoodbanner.batches-value') as string) + .replace('{confirmed}', String(batchesConfirmed)) + .replace('{total}', String(batchesTotal)); + // Next action: keep splitting, send the ready batch, or wait N blocks for // the next window. Amber (warning) while waiting, green (primary) to act. const nextActionText = splitting @@ -302,234 +154,263 @@ const IronwoodMigrationBanner: React.FunctionComponent< '{n}', String(batchesConfirmed + 1), ) - : confirming - ? (translate('ironwoodbanner.next-confirming') as string).replace( - '{n}', - String(batchesConfirmed + 1), - ) - : !nextWake - ? (translate('ironwoodbanner.next-all-sent') as string) - : (translate('ironwoodbanner.next-in-blocks') as string) - .replace('{n}', String(batchesConfirmed + 1)) - .replace('{blocks}', String(blocksUntil)); + : !nextWake + ? (translate('ironwoodbanner.next-all-sent') as string) + : (translate('ironwoodbanner.next-in-blocks') as string) + .replace('{n}', String(batchesConfirmed + 1)) + .replace('{blocks}', String(blocksUntil)); const nextActionActive = !splitting && (ready || !nextWake); - const nextActionColor = confirming - ? colors.syncing - : nextActionActive - ? colors.primary - : colors.warning.primary; + const nextActionColor = nextActionActive + ? colors.primary + : colors.warning.primary; return ( - - {/* Keyed so the swap between variants remounts the card and fades it - in, while the wrapper above persists and eases the height gap. */} - - onResume(resumeRoute)} + + onResume(resumeRoute)} + style={{ + backgroundColor: colors.bottomSheetBackground, + borderColor: colors.bottomSheetBorder, + borderWidth: 1, + borderRadius: 16, + paddingHorizontal: 18, + paddingVertical: 12, + }} + > + {/* Header: title + Details link */} + - {/* Header: title + Details link */} - - - {translate('ironwoodbanner.inflight-title') as string} - - - {translate('ironwoodbanner.details') as string} - - + {translate('ironwoodbanner.inflight-title') as string} + + + {translate('ironwoodbanner.details') as string} + + - {/* Progress bar + percentage */} + {/* Progress bar + percentage */} + - - {/* Whole-segment fill, never partial: a note confirms at once. - The broadcast batch lights up ahead of the confirmed run. */} - - - - {pct}% - + /> - - + > + {pct}% + + - {/* Next action */} - + + {/* Batches */} + + + {translate('ironwoodbanner.batches-label') as string} + + - - {translate('ironwoodbanner.next-action-label') as string} - - - + {batchesValue} + + - {/* Status + amount left */} + {/* Next action */} + + + {translate('ironwoodbanner.next-action-label') as string} + - - - - {statusLabel} - - - - {orchardLeftStr} + + {nextActionText} - - - - ); - } + - // ----- Default variant ----- - return ( - - - {/* Orchard pool card with the Start action */} - - - + {/* Status + amount left */} + + - {translate('ironwoodbanner.pool') as string} + {statusLabel} - - {translate('ironwoodbanner.balance') as string}{' '} - - {Utils.parseNumberFloatToStringLocale(amount, 4)} - {' '} - {currencyName} + + {orchardLeftStr} + + + ); + } - + {/* Orchard pool card with the Start action */} + + + - + - {translate('ironwoodbanner.start') as string} + {translate('ironwoodbanner.pool') as string} - + + + {translate('ironwoodbanner.balance') as string}{' '} + + {Utils.parseNumberFloatToStringLocale(amount, 4)} + {' '} + {currencyName} + - - + + + + {translate('ironwoodbanner.start') as string} + + + + ); }; diff --git a/components/MigrationCadence/MigrationCadence.tsx b/components/MigrationCadence/MigrationCadence.tsx deleted file mode 100644 index 8f8aad6b4..000000000 --- a/components/MigrationCadence/MigrationCadence.tsx +++ /dev/null @@ -1,446 +0,0 @@ -/* eslint-disable react-native/no-inline-styles */ -import React, { useCallback, useContext, useEffect, useState } from 'react'; -import { - ActivityIndicator, - ScrollView, - Text, - TouchableOpacity, - View, -} from 'react-native'; -import { useTheme } from '@react-navigation/native'; -import { NativeStackScreenProps } from '@react-navigation/native-stack'; - -import BoldText from '../Components/BoldText'; -import Button from '../Components/Button'; -import StepperHeader from '../Migration/StepperHeader'; -import { AppDrawerParamList, ThemeType } from '../../app/types'; -import { ContextAppLoaded } from '../../app/context'; -import { ButtonTypeEnum, RouteEnum } from '../../app/AppState'; -import { - migrationStatus, - planIronwoodMigration, - routeCadencePlan, - routeStartMigration, - startIronwoodMigration, -} from '../../app/walletBackend'; -import { RPCMigrationStatusType } from '../../app/walletBackend/types/RPCMigrationStatusType'; -import { RPCMigrationPlanType } from '../../app/walletBackend/types/RPCMigrationPlanType'; - -type MigrationCadenceProps = NativeStackScreenProps< - AppDrawerParamList, - RouteEnum.MigrationCadence ->; - -const ZATS_PER_ZEC = 10 ** 8; - -const fmt = (zats: number): string => - `${parseFloat((zats / ZATS_PER_ZEC).toFixed(4))}`; - -// Zcash target block spacing, for turning a window count into a duration. -const SECONDS_PER_BLOCK = 75; -// zingolib's provisional default cadence (MigrationParams.k_max); the status -// normally supplies the live value, this is only the offline fallback. -const DEFAULT_PER_BUCKET = 8; - -type CadenceChoice = 'fewer' | 'more'; - -// "~4 batches · finishes in about 21 hours" — the consequence line each -// preset card carries, so the user chooses with the real trade-off visible. -const durationText = ( - batches: number, - bucketModulus: number, - translate: (key: string) => string, -): string => { - const hours = Math.max( - 1, - Math.round((batches * bucketModulus * SECONDS_PER_BLOCK) / 3600), - ); - if (hours < 48) { - return translate('migrationcadence.duration-hours').replace( - '{n}', - String(hours), - ); - } - return translate('migrationcadence.duration-days').replace( - '{n}', - String(Math.round(hours / 24)), - ); -}; - -type PresetCardProps = { - title: string; - body: string; - batches: number; - duration: string; - selected: boolean; - onPress: () => void; - colors: ThemeType['colors']; -}; - -const PresetCard: React.FunctionComponent = ({ - title, - body, - batches, - duration, - selected, - onPress, - colors, -}) => ( - - - - {title} - - - ~{batches} - - - - {body} - - - {duration} - - -); - -// The Phase 2 cadence chooser ("How many batches?"), shown once splitting -// completes. The notes are fully split by now, so this is where Phase 2 consent -// is captured: "Review schedule" calls start_ironwood_migration with the chosen -// per-bucket cadence, binding the parts and scheduling them, then hands off to -// the schedule review screen that arms the reminders. There is no migration -// state yet, so the part count and the fresh consent hash come from a live -// plan_ironwood_migration read (ADR 0016), and the bucket cadence params from -// migration_status's provisional values. -const MigrationCadence: React.FunctionComponent = ({ - navigation, -}) => { - const context = useContext(ContextAppLoaded); - const { translate, addLastSnackbar } = context; - const { colors } = useTheme() as ThemeType; - - const [status, setStatus] = useState(null); - const [plan, setPlan] = useState(null); - const [loading, setLoading] = useState(true); - const [errorMsg, setErrorMsg] = useState(null); - const [selected, setSelected] = useState('fewer'); - const [submitting, setSubmitting] = useState(false); - - const load = useCallback(async () => { - setLoading(true); - setErrorMsg(null); - const [statusResult, planResult] = await Promise.all([ - migrationStatus(), - planIronwoodMigration(), - ]); - if (!statusResult.ok) { - setErrorMsg(statusResult.error.message); - setLoading(false); - return; - } - if (!planResult.ok) { - setErrorMsg(planResult.error.message); - setLoading(false); - return; - } - try { - const parsedStatus = JSON.parse( - statusResult.value, - ) as RPCMigrationStatusType; - const parsedPlan = JSON.parse(planResult.value) as RPCMigrationPlanType; - if (parsedStatus.error) { - setErrorMsg(parsedStatus.error); - } else if (parsedPlan.error) { - setErrorMsg(parsedPlan.error); - } else { - setStatus(parsedStatus); - setPlan(parsedPlan); - } - } catch (e) { - setErrorMsg(`${e}`); - } - setLoading(false); - }, []); - - useEffect(() => { - load(); - }, [load]); - - const planRoute = plan ? routeCadencePlan(plan) : null; - const parts = planRoute?.kind === 'choose' ? planRoute.parts : 0; - const bucketModulus = status?.bucket_modulus ?? 144; - // The "fewer" preset IS zingolib's default cadence, so we never invent a - // second opinion about a privacy parameter; "more" is maximum dispersion. - const fewerPerBucket = status?.per_bucket ?? DEFAULT_PER_BUCKET; - const fewerBatches = Math.max( - 1, - Math.ceil(parts / Math.max(1, fewerPerBucket)), - ); - const moreBatches = Math.max(1, parts); - - const windowHours = ((bucketModulus * SECONDS_PER_BLOCK) / 3600).toFixed(1); - - // Review = Phase 2 consent: start_ironwood_migration binds the parts to the - // now-split notes and schedules them under the chosen cadence. Its consent - // hash is the plan we just read (post-split), not the pre-split one. - const onReview = useCallback(async () => { - if (submitting || !plan?.plan_hash) { - return; - } - const perBucket = selected === 'fewer' ? fewerPerBucket : 1; - setSubmitting(true); - const start = await startIronwoodMigration(plan.plan_hash, perBucket); - setSubmitting(false); - const route = routeStartMigration(start); - switch (route.kind) { - case 'proceed': - navigation.navigate(RouteEnum.MigrationSchedule, { perBucket }); - return; - // A migration already exists (re-entry after Phase 2 already started): - // its schedule stands — review it. - case 'resume': - navigation.navigate(RouteEnum.MigrationSchedule, { perBucket }); - return; - // ConsentStale: notes changed between the plan read and this call. Reload - // the fresh figures so the user reviews and schedules against them. - case 'replan': - addLastSnackbar(translate('migrationcadence.replanned') as string); - load(); - return; - case 'error': - addLastSnackbar(route.message); - } - }, [ - submitting, - plan, - selected, - fewerPerBucket, - navigation, - addLastSnackbar, - translate, - load, - ]); - - // ----- Loading / error ----- - if (loading || errorMsg) { - return ( - - {loading ? ( - - ) : ( - - {errorMsg} - - )} - - ); - } - - // ----- Nothing to schedule ----- - // Consenting to a plan with no notes binds a migration with no batches and - // no way forward, so both empty plans stop here. Dust is terminal; an - // unanchored split resolves in a block or two, which is what Try again is - // for. - if (planRoute && planRoute.kind !== 'choose') { - const dust = planRoute.kind === 'dust'; - return ( - - - - - { - translate( - dust - ? 'migrationcadence.dust-title' - : 'migrationcadence.unconfirmed-title', - ) as string - } - - - {dust - ? (translate('migrationcadence.dust-body') as string).replace( - '{amount}', - fmt(planRoute.residual), - ) - : (translate('migrationcadence.unconfirmed-body') as string)} - - - -