@onekeyfe/hd-core 1.2.2-alpha.9 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +1 -1
  2. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +503 -10
  3. package/__tests__/DeviceCommands.test.ts +254 -1
  4. package/__tests__/core-error-output.test.ts +169 -1
  5. package/__tests__/device-lifecycle-events.test.ts +395 -15
  6. package/__tests__/logBlockEvent.test.ts +45 -122
  7. package/__tests__/open-wallet-session-error-response.test.ts +2 -2
  8. package/__tests__/open-wallet-session.test.ts +8 -358
  9. package/__tests__/protocol-v2.test.ts +130 -0
  10. package/__tests__/public-device-state-api.test.ts +2 -7
  11. package/__tests__/search-devices.test.ts +196 -8
  12. package/__tests__/sol-sign-offchain-message.test.ts +64 -0
  13. package/dist/api/FirmwareUpdateV4.d.ts +1 -0
  14. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  15. package/dist/api/GetFeatures.d.ts.map +1 -1
  16. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  17. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  18. package/dist/api/SearchDevices.d.ts +2 -15
  19. package/dist/api/SearchDevices.d.ts.map +1 -1
  20. package/dist/api/UploadPortfolio.d.ts +1 -0
  21. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  22. package/dist/api/allnetwork/AllNetworkGetAddress.d.ts +2 -0
  23. package/dist/api/allnetwork/AllNetworkGetAddress.d.ts.map +1 -1
  24. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts +7 -1
  25. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  26. package/dist/api/allnetwork/AllNetworkGetAddressByLoop.d.ts.map +1 -1
  27. package/dist/api/device/DeviceVerify.d.ts.map +1 -1
  28. package/dist/api/solana/SolSignOffchainMessage.d.ts.map +1 -1
  29. package/dist/core/RequestQueue.d.ts +1 -0
  30. package/dist/core/RequestQueue.d.ts.map +1 -1
  31. package/dist/core/index.d.ts +3 -1
  32. package/dist/core/index.d.ts.map +1 -1
  33. package/dist/core/uiPromiseRegistry.d.ts +1 -1
  34. package/dist/data-manager/TransportManager.d.ts +2 -0
  35. package/dist/data-manager/TransportManager.d.ts.map +1 -1
  36. package/dist/device/Device.d.ts +2 -0
  37. package/dist/device/Device.d.ts.map +1 -1
  38. package/dist/device/DeviceCommands.d.ts +5 -4
  39. package/dist/device/DeviceCommands.d.ts.map +1 -1
  40. package/dist/events/logBlockEvent.d.ts.map +1 -1
  41. package/dist/index.d.ts +35 -30
  42. package/dist/index.js +587 -314
  43. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  44. package/dist/types/api/getFeatures.d.ts.map +1 -1
  45. package/dist/types/api/getPassphraseState.d.ts.map +1 -1
  46. package/dist/types/api/openWalletSession.d.ts +1 -10
  47. package/dist/types/api/openWalletSession.d.ts.map +1 -1
  48. package/dist/types/api/protocolV2.d.ts +3 -4
  49. package/dist/types/api/protocolV2.d.ts.map +1 -1
  50. package/dist/types/api/solSignOffchainMessage.d.ts +1 -0
  51. package/dist/types/api/solSignOffchainMessage.d.ts.map +1 -1
  52. package/dist/types/params.d.ts.map +1 -1
  53. package/dist/utils/patch.d.ts +1 -1
  54. package/dist/utils/patch.d.ts.map +1 -1
  55. package/package.json +4 -4
  56. package/src/api/FirmwareUpdateV4.ts +9 -4
  57. package/src/api/GetFeatures.ts +1 -0
  58. package/src/api/GetPassphraseState.ts +1 -0
  59. package/src/api/OpenWalletSession.ts +7 -77
  60. package/src/api/SearchDevices.ts +121 -27
  61. package/src/api/UploadPortfolio.ts +5 -4
  62. package/src/api/allnetwork/AllNetworkGetAddress.ts +79 -45
  63. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +95 -24
  64. package/src/api/allnetwork/AllNetworkGetAddressByLoop.ts +3 -0
  65. package/src/api/device/DeviceVerify.ts +8 -0
  66. package/src/api/solana/SolSignOffchainMessage.ts +44 -4
  67. package/src/core/RequestQueue.ts +20 -0
  68. package/src/core/index.ts +159 -52
  69. package/src/data/messages/messages-protocol-v2.json +49 -33
  70. package/src/data/messages/messages.json +8 -5
  71. package/src/data-manager/TransportManager.ts +14 -3
  72. package/src/device/Device.ts +58 -27
  73. package/src/device/DeviceCommands.ts +29 -2
  74. package/src/events/logBlockEvent.ts +6 -75
  75. package/src/protocols/protocol-v2/walletSession.ts +14 -2
  76. package/src/types/api/getFeatures.ts +2 -1
  77. package/src/types/api/getPassphraseState.ts +2 -5
  78. package/src/types/api/openWalletSession.ts +7 -19
  79. package/src/types/api/protocolV2.ts +3 -4
  80. package/src/types/api/solSignOffchainMessage.ts +2 -0
  81. package/src/types/params.ts +7 -0
@@ -17,7 +17,7 @@ jest.mock('../src/data-manager/TransportManager', () => ({
17
17
  }));
18
18
 
19
19
  describe('openWalletSession error response', () => {
20
- test('returns a structured invalid-parameter response for an incomplete resume request', async () => {
20
+ test('returns a structured invalid-parameter response for the removed resume-hidden mode', async () => {
21
21
  const core = initCore();
22
22
 
23
23
  const response = await core.handleMessage({
@@ -34,7 +34,7 @@ describe('openWalletSession error response', () => {
34
34
  expect(response).toMatchObject({
35
35
  success: false,
36
36
  payload: {
37
- error: 'Missing required parameter: deviceId',
37
+ error: 'Parameter [mode] must be one of standard or select-hidden.',
38
38
  code: HardwareErrorCode.CallMethodInvalidParameter,
39
39
  },
40
40
  });
@@ -982,45 +982,6 @@ describe('openWalletSession', () => {
982
982
  );
983
983
  });
984
984
 
985
- test('uses an explicit wallet binding to resume on Protocol V2', async () => {
986
- const typedCall = jest
987
- .fn()
988
- .mockResolvedValueOnce({ message: { version: 2 } })
989
- .mockResolvedValueOnce({
990
- message: {
991
- btc_test_address: 'hidden-state',
992
- session_id: 'renewed-session',
993
- },
994
- });
995
- const promptPassphrase = jest.fn();
996
- const method = new OpenWalletSession({
997
- payload: {
998
- method: 'openWalletSession',
999
- connectId: 'connect-id',
1000
- mode: 'resume-hidden',
1001
- deviceId: 'device-1',
1002
- passphraseState: 'hidden-state',
1003
- },
1004
- });
1005
- method.init();
1006
- deviceWalletSessionStore.set('device-1', 'hidden-state', 'known-session');
1007
- method.device = createDevice({ typedCall, promptPassphrase }) as any;
1008
-
1009
- await expect(method.run()).resolves.toEqual({
1010
- protocol: 'V2',
1011
- walletType: 'hidden',
1012
- deviceId: 'device-1',
1013
- passphraseState: 'hidden-state',
1014
- resumed: true,
1015
- });
1016
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1017
- session_id: 'known-session',
1018
- btc_test_address: 'hidden-state',
1019
- ...standardSessionGet,
1020
- });
1021
- expect(promptPassphrase).not.toHaveBeenCalled();
1022
- });
1023
-
1024
985
  test.each([
1025
986
  {
1026
987
  mode: 'standard',
@@ -1035,12 +996,6 @@ describe('openWalletSession', () => {
1035
996
  deviceId: 'device-1',
1036
997
  passphraseState: 'hidden-state',
1037
998
  },
1038
- {
1039
- mode: 'resume-hidden',
1040
- deviceId: 'device-1',
1041
- passphraseState: 'hidden-state',
1042
- initSession: false,
1043
- },
1044
999
  ])('rejects legacy parameters combined with explicit mode: %o', params => {
1045
1000
  const method = new OpenWalletSession({
1046
1001
  payload: {
@@ -1076,63 +1031,7 @@ describe('openWalletSession', () => {
1076
1031
  }
1077
1032
  );
1078
1033
 
1079
- test('reuses the SDK-managed Protocol V1 hidden-wallet session before validation', async () => {
1080
- const typedCall = jest.fn().mockResolvedValue({
1081
- type: 'PassphraseState',
1082
- message: {
1083
- passphrase_state: 'hidden-state',
1084
- session_id: 'known-session',
1085
- unlocked_attach_pin: false,
1086
- },
1087
- });
1088
- const method = new OpenWalletSession({
1089
- payload: {
1090
- method: 'openWalletSession',
1091
- connectId: 'connect-id',
1092
- mode: 'resume-hidden',
1093
- deviceId: 'device-1',
1094
- passphraseState: 'hidden-state',
1095
- },
1096
- });
1097
- method.init();
1098
- deviceWalletSessionStore.set('device-1', 'hidden-state', 'known-session');
1099
- const device = createDevice({ typedCall });
1100
- device.isProtocolV2 = () => false;
1101
- device.features = {
1102
- ...device.features,
1103
- deviceId: 'device-1',
1104
- unlocked: true,
1105
- sessionId: null,
1106
- };
1107
- device.getCurrentFirmwareVersionString = () => '4.15.0';
1108
- device.getCurrentDeviceType = () => EDeviceType.Pro;
1109
- device.getFeatures = jest.fn();
1110
- method.device = device as any;
1111
-
1112
- await expect(method.run()).resolves.toMatchObject({
1113
- protocol: 'V1',
1114
- walletType: 'hidden',
1115
- passphraseState: 'hidden-state',
1116
- resumed: true,
1117
- });
1118
- expect(device.initialize).toHaveBeenCalledWith({
1119
- deviceId: 'device-1',
1120
- passphraseState: 'hidden-state',
1121
- });
1122
- expect(typedCall).toHaveBeenCalledWith('GetPassphraseState', 'PassphraseState', {
1123
- passphrase_state: 'hidden-state',
1124
- });
1125
- });
1126
-
1127
- test('rejects a Protocol V1 resume when the device returns another hidden wallet', async () => {
1128
- const typedCall = jest.fn().mockResolvedValue({
1129
- type: 'PassphraseState',
1130
- message: {
1131
- passphrase_state: 'other-hidden-state',
1132
- session_id: 'other-session',
1133
- unlocked_attach_pin: false,
1134
- },
1135
- });
1034
+ test('rejects the removed resume-hidden mode', () => {
1136
1035
  const method = new OpenWalletSession({
1137
1036
  payload: {
1138
1037
  method: 'openWalletSession',
@@ -1140,28 +1039,15 @@ describe('openWalletSession', () => {
1140
1039
  mode: 'resume-hidden',
1141
1040
  deviceId: 'device-1',
1142
1041
  passphraseState: 'hidden-state',
1143
- },
1042
+ } as any,
1144
1043
  });
1145
- method.init();
1146
- deviceWalletSessionStore.set('device-1', 'hidden-state', 'known-session');
1147
- const device = createDevice({ typedCall });
1148
- device.isProtocolV2 = () => false;
1149
- device.features = {
1150
- ...device.features,
1151
- deviceId: 'device-1',
1152
- unlocked: true,
1153
- sessionId: null,
1154
- };
1155
- device.getCurrentFirmwareVersionString = () => '4.15.0';
1156
- device.getCurrentDeviceType = () => EDeviceType.Pro;
1157
- device.getFeatures = jest.fn();
1158
- method.device = device as any;
1159
1044
 
1160
- await expect(method.run()).rejects.toMatchObject({
1161
- errorCode: HardwareErrorCode.DeviceCheckPassphraseStateError,
1162
- });
1163
- expect(device.clearInternalState).toHaveBeenCalledTimes(1);
1164
- expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBeUndefined();
1045
+ expect(() => method.init()).toThrow(
1046
+ expect.objectContaining({
1047
+ errorCode: HardwareErrorCode.CallMethodInvalidParameter,
1048
+ message: 'Parameter [mode] must be one of standard or select-hidden.',
1049
+ })
1050
+ );
1165
1051
  });
1166
1052
 
1167
1053
  test('opens the standard wallet without selecting a hidden wallet', async () => {
@@ -1882,242 +1768,6 @@ describe('openWalletSession', () => {
1882
1768
  }
1883
1769
  );
1884
1770
 
1885
- test('resumes a known hidden wallet without prompting or device selection', async () => {
1886
- const typedCall = jest
1887
- .fn()
1888
- .mockResolvedValueOnce({ message: { version: 2 } })
1889
- .mockResolvedValueOnce({
1890
- message: {
1891
- btc_test_address: 'hidden-state',
1892
- session_id: 'renewed-session',
1893
- },
1894
- });
1895
- const promptPassphrase = jest.fn();
1896
- const method = new OpenWalletSession({
1897
- payload: {
1898
- method: 'openWalletSession',
1899
- connectId: 'connect-id',
1900
- mode: 'resume-hidden',
1901
- deviceId: 'device-1',
1902
- passphraseState: 'hidden-state',
1903
- },
1904
- });
1905
- method.init();
1906
- deviceWalletSessionStore.set('device-1', 'hidden-state', 'known-session');
1907
- method.device = createDevice({ typedCall, promptPassphrase }) as any;
1908
-
1909
- await expect(method.run()).resolves.toEqual({
1910
- protocol: 'V2',
1911
- walletType: 'hidden',
1912
- deviceId: 'device-1',
1913
- passphraseState: 'hidden-state',
1914
- resumed: true,
1915
- });
1916
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1917
- session_id: 'known-session',
1918
- btc_test_address: 'hidden-state',
1919
- ...standardSessionGet,
1920
- });
1921
- expect(promptPassphrase).not.toHaveBeenCalled();
1922
- });
1923
-
1924
- test('validates a locked Protocol V2 resume binding after deviceId refresh', async () => {
1925
- const typedCall = jest
1926
- .fn()
1927
- .mockResolvedValueOnce({ message: { version: 2 } })
1928
- .mockResolvedValueOnce({
1929
- message: {
1930
- btc_test_address: 'hidden-state',
1931
- session_id: 'renewed-session',
1932
- },
1933
- });
1934
- const method = new OpenWalletSession({
1935
- payload: {
1936
- method: 'openWalletSession',
1937
- connectId: 'connect-id',
1938
- mode: 'resume-hidden',
1939
- deviceId: 'device-1',
1940
- passphraseState: 'hidden-state',
1941
- },
1942
- });
1943
- method.init();
1944
- deviceWalletSessionStore.set('device-1', 'hidden-state', 'known-session');
1945
- const device = createDevice({ typedCall });
1946
- device.features.unlocked = false;
1947
- device.getDeviceState = jest
1948
- .fn()
1949
- .mockResolvedValueOnce({
1950
- identity: { deviceId: undefined },
1951
- status: { unlocked: false, passphraseProtection: true },
1952
- })
1953
- .mockResolvedValueOnce({
1954
- identity: { deviceId: 'device-1' },
1955
- status: {
1956
- unlocked: true,
1957
- passphraseProtection: true,
1958
- unlockedAttachPin: false,
1959
- },
1960
- });
1961
- device.unlockDevice = jest.fn().mockImplementation(() => {
1962
- device.features.unlocked = true;
1963
- return Promise.resolve(device.features);
1964
- });
1965
- method.device = device as any;
1966
-
1967
- await expect(method.run()).resolves.toMatchObject({
1968
- protocol: 'V2',
1969
- walletType: 'hidden',
1970
- deviceId: 'device-1',
1971
- passphraseState: 'hidden-state',
1972
- resumed: true,
1973
- });
1974
- expect(device.unlockDevice).toHaveBeenCalledTimes(1);
1975
- expect(device.getDeviceState).toHaveBeenCalledTimes(2);
1976
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
1977
- session_id: 'known-session',
1978
- btc_test_address: 'hidden-state',
1979
- ...standardSessionGet,
1980
- });
1981
- const sessionGetCall = typedCall.mock.calls.findIndex(
1982
- ([requestName]) => requestName === 'DeviceSessionGet'
1983
- );
1984
- expect(device.getDeviceState.mock.invocationCallOrder[1]).toBeLessThan(
1985
- typedCall.mock.invocationCallOrder[sessionGetCall]
1986
- );
1987
- });
1988
-
1989
- test('rejects a Protocol V2 resume when the refreshed deviceId does not match', async () => {
1990
- const typedCall = jest.fn().mockResolvedValue({
1991
- message: {
1992
- btc_test_address: 'hidden-state',
1993
- session_id: 'renewed-session',
1994
- },
1995
- });
1996
- const method = new OpenWalletSession({
1997
- payload: {
1998
- method: 'openWalletSession',
1999
- connectId: 'connect-id',
2000
- mode: 'resume-hidden',
2001
- deviceId: 'device-1',
2002
- passphraseState: 'hidden-state',
2003
- },
2004
- });
2005
- method.init();
2006
- deviceWalletSessionStore.set('device-1', 'hidden-state', 'known-session');
2007
- const device = createDevice({ typedCall });
2008
- device.features.unlocked = false;
2009
- device.getDeviceState = jest
2010
- .fn()
2011
- .mockResolvedValueOnce({
2012
- identity: { deviceId: undefined },
2013
- status: { passphraseProtection: true },
2014
- })
2015
- .mockResolvedValueOnce({
2016
- identity: { deviceId: 'other-device' },
2017
- status: {
2018
- unlocked: true,
2019
- passphraseProtection: true,
2020
- unlockedAttachPin: false,
2021
- },
2022
- });
2023
- device.unlockDevice = jest.fn().mockImplementation(() => {
2024
- device.features.unlocked = true;
2025
- return Promise.resolve(device.features);
2026
- });
2027
- method.device = device as any;
2028
-
2029
- await expect(method.run()).rejects.toMatchObject({
2030
- errorCode: HardwareErrorCode.DeviceCheckDeviceIdError,
2031
- });
2032
- expect(typedCall).not.toHaveBeenCalled();
2033
- expect(device.clearInternalState).not.toHaveBeenCalled();
2034
- expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBeUndefined();
2035
- });
2036
-
2037
- test('reselects the expected hidden wallet when a cached Protocol V2 session is invalid', async () => {
2038
- const typedCall = jest
2039
- .fn()
2040
- .mockResolvedValueOnce({ message: { version: 2 } })
2041
- .mockRejectedValueOnce(
2042
- Object.assign(new Error('Invalid session'), {
2043
- errorCode: HardwareErrorCode.WalletSessionInvalid,
2044
- })
2045
- )
2046
- .mockResolvedValueOnce({ message: {} })
2047
- .mockResolvedValueOnce({
2048
- message: {
2049
- btc_test_address: 'hidden-state',
2050
- session_id: 'renewed-session',
2051
- },
2052
- });
2053
- const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' });
2054
- const method = new OpenWalletSession({
2055
- payload: {
2056
- method: 'openWalletSession',
2057
- connectId: 'connect-id',
2058
- mode: 'resume-hidden',
2059
- deviceId: 'device-1',
2060
- passphraseState: 'hidden-state',
2061
- },
2062
- });
2063
- method.init();
2064
- deviceWalletSessionStore.set('device-1', 'hidden-state', 'expired-session');
2065
- method.device = createDevice({ typedCall, promptPassphrase }) as any;
2066
-
2067
- await expect(method.run()).resolves.toEqual({
2068
- protocol: 'V2',
2069
- walletType: 'hidden',
2070
- deviceId: 'device-1',
2071
- passphraseState: 'hidden-state',
2072
- resumed: false,
2073
- });
2074
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2075
- session_id: 'expired-session',
2076
- btc_test_address: 'hidden-state',
2077
- ...standardSessionGet,
2078
- });
2079
- expect(promptPassphrase).toHaveBeenCalledTimes(1);
2080
- expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBe('renewed-session');
2081
- });
2082
-
2083
- test('selects the expected hidden wallet when Protocol V2 has no cached session', async () => {
2084
- const typedCall = jest
2085
- .fn()
2086
- .mockResolvedValueOnce({ message: { version: 2 } })
2087
- .mockResolvedValueOnce({
2088
- message: {
2089
- btc_test_address: 'hidden-state',
2090
- session_id: 'new-session',
2091
- },
2092
- });
2093
- const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'host hidden wallet' });
2094
- const method = new OpenWalletSession({
2095
- payload: {
2096
- method: 'openWalletSession',
2097
- connectId: 'connect-id',
2098
- mode: 'resume-hidden',
2099
- deviceId: 'device-1',
2100
- passphraseState: 'hidden-state',
2101
- },
2102
- });
2103
- method.init();
2104
- method.device = createDevice({ typedCall, promptPassphrase }) as any;
2105
-
2106
- await expect(method.run()).resolves.toEqual({
2107
- protocol: 'V2',
2108
- walletType: 'hidden',
2109
- deviceId: 'device-1',
2110
- passphraseState: 'hidden-state',
2111
- resumed: false,
2112
- });
2113
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
2114
- btc_test_address: 'hidden-state',
2115
- ...standardSessionGet,
2116
- });
2117
- expect(promptPassphrase).not.toHaveBeenCalled();
2118
- expect(deviceWalletSessionStore.get('device-1', 'hidden-state')).toBe('new-session');
2119
- });
2120
-
2121
1771
  test.each([false, true])(
2122
1772
  'openWalletSession does not thread CommonParams.deriveCardano: %s',
2123
1773
  async deriveCardano => {
@@ -1,4 +1,5 @@
1
1
  import { Buffer } from 'buffer';
2
+ import { randomBytes } from 'crypto';
2
3
  import { EDeviceType, EFirmwareType, ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
3
4
  import { sha256 } from '@noble/hashes/sha256';
4
5
  import { bytesToHex } from '@noble/hashes/utils';
@@ -536,6 +537,41 @@ describe('UploadPortfolio', () => {
536
537
  });
537
538
  });
538
539
 
540
+ test('emits transfer progress when progress UI is requested', async () => {
541
+ const typedCall = jest
542
+ .fn()
543
+ .mockResolvedValueOnce({ message: { processed_byte: 3 } })
544
+ .mockResolvedValueOnce({ message: { message: 'Portfolio updated' } });
545
+ const method = new UploadPortfolio({
546
+ id: 1,
547
+ payload: {
548
+ method: 'uploadPortfolio',
549
+ packageBase64: 'AQID',
550
+ uiMode: 'progress',
551
+ },
552
+ });
553
+ (method as any).device = stubPortfolioDevice({ commands: { typedCall } });
554
+ method.postMessage = jest.fn();
555
+
556
+ method.init();
557
+ await method.run();
558
+
559
+ expect(method.unlockPolicy).toBe('none');
560
+ expect(method.protocolV2UiMode).toBe('auto');
561
+ expect(method.protocolV2UiInteraction).toBeUndefined();
562
+ expect(method.payload.emitProgress).toBe(true);
563
+ expect(method.postMessage).toHaveBeenCalledWith({
564
+ event: 'UI_EVENT',
565
+ type: UI_REQUEST.DEVICE_PROGRESS,
566
+ payload: expect.objectContaining({
567
+ progress: 100,
568
+ transferredBytes: 3,
569
+ totalBytes: 3,
570
+ elapsedMs: expect.any(Number),
571
+ }),
572
+ });
573
+ });
574
+
539
575
  test('does not apply PortfolioUpdate when staging fails', async () => {
540
576
  const typedCall = jest.fn().mockRejectedValue(new Error('write failed'));
541
577
  const method = new UploadPortfolio({
@@ -1789,6 +1825,56 @@ describe('Protocol V2 feature adapter', () => {
1789
1825
  ]);
1790
1826
  });
1791
1827
 
1828
+ test.each([false, true])(
1829
+ 'rejects the first incorrect passphrase after a missing or expired session (cached: %s)',
1830
+ async cached => {
1831
+ const deviceId = `first-passphrase-error-${String(cached)}`;
1832
+ const device = Device.fromDescriptor({
1833
+ id: deviceId,
1834
+ path: deviceId,
1835
+ protocolType: 'V2',
1836
+ } as never);
1837
+ device.features = normalizeProtocolV2Features(
1838
+ { ...descriptor, protocolType: 'V2' } as never,
1839
+ {
1840
+ status: { device_id: deviceId, unlocked: true, passphrase_enabled: true },
1841
+ }
1842
+ );
1843
+ device.passphraseState = 'expected-public-address';
1844
+ if (cached) {
1845
+ preloadSessionCache(deviceId, device.passphraseState, randomBytes(32).toString('hex'));
1846
+ }
1847
+ let reads = 0;
1848
+ const typedCall = createWalletSessionTypedCall(
1849
+ jest.fn((request: string) => {
1850
+ if (request === 'DeviceSessionAskPassphrase') return { message: {} };
1851
+ if (request === 'DeviceSessionGet') {
1852
+ reads += 1;
1853
+ if (reads === 1) {
1854
+ throw ERRORS.TypedError(HardwareErrorCode.WalletSessionInvalid);
1855
+ }
1856
+ return {
1857
+ message: {
1858
+ session_id: randomBytes(32).toString('hex'),
1859
+ btc_test_address: 'different-public-address',
1860
+ },
1861
+ };
1862
+ }
1863
+ throw new Error(`Unexpected request: ${request}`);
1864
+ })
1865
+ );
1866
+ const promptPassphrase = jest.fn().mockResolvedValue({ passphraseOnDevice: true });
1867
+ device.commands = { typedCall, promptPassphrase } as never;
1868
+
1869
+ await expect(getProtocolV2WalletSession(device)).rejects.toMatchObject({
1870
+ errorCode: HardwareErrorCode.DeviceCheckPassphraseStateError,
1871
+ });
1872
+ expect(promptPassphrase).toHaveBeenCalledTimes(1);
1873
+ expect(reads).toBe(2);
1874
+ expect(device.getInternalState()).toBeUndefined();
1875
+ }
1876
+ );
1877
+
1792
1878
  test('reselects a hidden wallet when the cached Pro2 session resolves to another wallet', async () => {
1793
1879
  const device = Device.fromDescriptor({
1794
1880
  id: 'cache-device-mismatch',
@@ -8892,6 +8978,50 @@ describe('Protocol V2 firmware update targets', () => {
8892
8978
  expect(recoverProtocolV2FileTransfer).not.toHaveBeenCalled();
8893
8979
  });
8894
8980
 
8981
+ test('keeps public transfer bytes monotonic when a V4 file retry restarts at zero', async () => {
8982
+ const method = new FirmwareUpdateV4({
8983
+ id: 1,
8984
+ payload: {
8985
+ method: 'firmwareUpdateV4',
8986
+ },
8987
+ });
8988
+ let writeCount = 0;
8989
+ (method as any).fileWriteChunk = jest.fn(
8990
+ (_path: string, _size: number, offset: number, data: Uint8Array) => {
8991
+ writeCount += 1;
8992
+ if (writeCount === 3) {
8993
+ return Promise.reject(new Error('transport timeout'));
8994
+ }
8995
+ return Promise.resolve({
8996
+ message: { processed_byte: offset + data.byteLength },
8997
+ });
8998
+ }
8999
+ );
9000
+ (method as any).getProtocolV2FirmwareChunkSize = jest.fn().mockReturnValue(1000);
9001
+ (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
9002
+ method.postProgressMessage = jest.fn();
9003
+ const source = await openFirmwareByteSource({
9004
+ binary: new Uint8Array(3000).buffer,
9005
+ });
9006
+
9007
+ try {
9008
+ await (method as any).protocolV2SourceUpdateProcess({
9009
+ source,
9010
+ filePath: 'vol1:firmware.bin',
9011
+ processedSize: 0,
9012
+ totalSize: 3000,
9013
+ });
9014
+ } finally {
9015
+ await source?.close();
9016
+ }
9017
+
9018
+ const transferredBytes = (method.postProgressMessage as jest.Mock).mock.calls.map(
9019
+ ([, , metrics]) => metrics.transferredBytes
9020
+ );
9021
+ expect(transferredBytes).toEqual([1000, 2000, 3000]);
9022
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
9023
+ });
9024
+
8895
9025
  test('throttles repeated transfer progress while preserving file completion', async () => {
8896
9026
  const method = new FirmwareUpdateV4({
8897
9027
  id: 1,
@@ -22,7 +22,6 @@ describe('public device state API boundary', () => {
22
22
  expect((publicTypes as Record<string, unknown>).OpenWalletSessionMode).toEqual({
23
23
  Standard: 'standard',
24
24
  SelectHidden: 'select-hidden',
25
- ResumeHidden: 'resume-hidden',
26
25
  });
27
26
  });
28
27
 
@@ -83,9 +82,7 @@ describe('public device state API boundary', () => {
83
82
  useEmptyPassphrase: false,
84
83
  });
85
84
  await api.openWalletSession('device-2', {
86
- mode: publicTypes.OpenWalletSessionMode.ResumeHidden,
87
- deviceId: 'wallet-device-1',
88
- passphraseState: 'wallet-state-1',
85
+ mode: publicTypes.OpenWalletSessionMode.SelectHidden,
89
86
  });
90
87
  await api.clearSessionCache({
91
88
  deviceId: 'wallet-device-1',
@@ -100,9 +97,7 @@ describe('public device state API boundary', () => {
100
97
  });
101
98
  expect(call).toHaveBeenNthCalledWith(2, {
102
99
  connectId: 'device-2',
103
- mode: 'resume-hidden',
104
- deviceId: 'wallet-device-1',
105
- passphraseState: 'wallet-state-1',
100
+ mode: 'select-hidden',
106
101
  method: 'openWalletSession',
107
102
  });
108
103
  expect(call).toHaveBeenNthCalledWith(3, {