@neuraiproject/neurai-depin-terminal 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/config.example.json +10 -0
- package/dist/index.cjs +218 -0
- package/package.json +61 -0
- package/src/config/ConfigManager.js +363 -0
- package/src/constants.js +208 -0
- package/src/errors.js +149 -0
- package/src/index.js +528 -0
- package/src/lib/depinMsgLoader.js +73 -0
- package/src/lib/empty.js +2 -0
- package/src/messaging/MessagePoller.js +300 -0
- package/src/messaging/MessageSender.js +194 -0
- package/src/messaging/MessageStore.js +99 -0
- package/src/services/RpcService.js +200 -0
- package/src/ui/TerminalUI.js +272 -0
- package/src/ui/components/ErrorOverlay.js +99 -0
- package/src/ui/components/InputBox.js +63 -0
- package/src/ui/components/MessageBox.js +51 -0
- package/src/ui/components/StatusBar.js +32 -0
- package/src/ui/components/TopBar.js +63 -0
- package/src/utils.js +309 -0
- package/src/wallet/WalletManager.js +94 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import blessed from 'blessed';
|
|
2
|
+
import { UI, COLORS, ICONS } from '../../constants.js';
|
|
3
|
+
import { parseRpcHost, formatTimestamp } from '../../utils.js';
|
|
4
|
+
|
|
5
|
+
export class TopBar {
|
|
6
|
+
constructor(screen, config, myAddress) {
|
|
7
|
+
this.screen = screen;
|
|
8
|
+
this.config = config;
|
|
9
|
+
this.myAddress = myAddress;
|
|
10
|
+
this.totalMessages = 0;
|
|
11
|
+
this.encryptionType = 'N/A';
|
|
12
|
+
|
|
13
|
+
this.component = blessed.box({
|
|
14
|
+
top: 0,
|
|
15
|
+
left: 0,
|
|
16
|
+
width: '100%',
|
|
17
|
+
height: UI.TOP_BAR_HEIGHT,
|
|
18
|
+
content: 'Loading...',
|
|
19
|
+
tags: true,
|
|
20
|
+
style: {
|
|
21
|
+
fg: COLORS.FG_WHITE,
|
|
22
|
+
bg: COLORS.BG_BLUE
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
this.screen.append(this.component);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
update(status) {
|
|
30
|
+
const rpcUrl = parseRpcHost(this.config.rpc_url);
|
|
31
|
+
const connectedIndicator = status.connected ?
|
|
32
|
+
`{${COLORS.CONNECTED}}${ICONS.CONNECTED}{/${COLORS.CONNECTED}}` :
|
|
33
|
+
`{${COLORS.DISCONNECTED}}${ICONS.DISCONNECTED}{/${COLORS.DISCONNECTED}}`;
|
|
34
|
+
|
|
35
|
+
const lastPollStr = status.lastPoll ?
|
|
36
|
+
formatTimestamp(status.lastPoll, this.config.timezone) :
|
|
37
|
+
'--:--:--';
|
|
38
|
+
|
|
39
|
+
// Format timezone display
|
|
40
|
+
let timezoneDisplay = this.config.timezone || 'UTC';
|
|
41
|
+
if (timezoneDisplay !== 'UTC') {
|
|
42
|
+
if (!timezoneDisplay.startsWith('+') && !timezoneDisplay.startsWith('-')) {
|
|
43
|
+
timezoneDisplay = `+${timezoneDisplay}`;
|
|
44
|
+
}
|
|
45
|
+
timezoneDisplay = `UTC${timezoneDisplay}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.component.setContent(
|
|
49
|
+
`Neurai DePIN | ${connectedIndicator} RPC: ${rpcUrl} | Token: ${this.config.token} | Time: ${timezoneDisplay}\n` +
|
|
50
|
+
`Address: ${this.myAddress} | Total: ${this.totalMessages} | Encryption: ${this.encryptionType} | Last poll: ${lastPollStr}`
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
this.screen.render();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setTotalMessages(count) {
|
|
57
|
+
this.totalMessages = count;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setEncryptionType(type) {
|
|
61
|
+
this.encryptionType = type;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for Neurai DePIN Terminal
|
|
3
|
+
* @module utils
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { KEY_CODES, TERMINAL } from './constants.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sleep for a specified duration
|
|
10
|
+
* @param {number} ms - Milliseconds to sleep
|
|
11
|
+
* @returns {Promise<void>}
|
|
12
|
+
*/
|
|
13
|
+
export function sleep(ms) {
|
|
14
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate a URL string
|
|
19
|
+
* @param {string} url - URL to validate
|
|
20
|
+
* @returns {boolean} True if valid URL
|
|
21
|
+
*/
|
|
22
|
+
export function isValidUrl(url) {
|
|
23
|
+
try {
|
|
24
|
+
new URL(url);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Validate a timezone string (UTC or numeric offset)
|
|
33
|
+
* @param {string} timezone - Timezone to validate
|
|
34
|
+
* @returns {boolean} True if valid timezone
|
|
35
|
+
*/
|
|
36
|
+
export function isValidTimezone(timezone) {
|
|
37
|
+
if (timezone === 'UTC') return true;
|
|
38
|
+
// Allow numeric offsets like +1, -5, +5.5, -3.5, 2, -2
|
|
39
|
+
const offsetRegex = /^[+-]?\d+(\.\d+)?$/;
|
|
40
|
+
return offsetRegex.test(timezone);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Ensure RPC URL has the correct format
|
|
45
|
+
* @param {string} url - Base RPC URL
|
|
46
|
+
* @param {string} suffix - Suffix to append (default: '/rpc')
|
|
47
|
+
* @returns {string} Formatted RPC URL
|
|
48
|
+
*/
|
|
49
|
+
export function formatRpcUrl(url, suffix = '/rpc') {
|
|
50
|
+
if (!url.endsWith(suffix)) {
|
|
51
|
+
return url + suffix;
|
|
52
|
+
}
|
|
53
|
+
return url;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Truncate a string with ellipsis
|
|
58
|
+
* @param {string} str - String to truncate
|
|
59
|
+
* @param {number} length - Maximum length
|
|
60
|
+
* @param {string} [suffix='...'] - Suffix to append
|
|
61
|
+
* @returns {string} Truncated string
|
|
62
|
+
*/
|
|
63
|
+
export function truncate(str, length, suffix = '...') {
|
|
64
|
+
if (!str || str.length <= length) {
|
|
65
|
+
return str;
|
|
66
|
+
}
|
|
67
|
+
return str.slice(0, length) + suffix;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Format timestamp to locale time string
|
|
72
|
+
* @param {number|Date} timestamp - Unix timestamp in seconds or Date object
|
|
73
|
+
* @param {string} [timezone='UTC'] - Timezone offset (e.g., 'UTC', '+1', '-5')
|
|
74
|
+
* @returns {string} Formatted time string
|
|
75
|
+
*/
|
|
76
|
+
export function formatTimestamp(timestamp, timezone = 'UTC') {
|
|
77
|
+
const date = timestamp instanceof Date ? timestamp : new Date(timestamp * 1000);
|
|
78
|
+
|
|
79
|
+
// Handle UTC explicitly
|
|
80
|
+
if (timezone === 'UTC') {
|
|
81
|
+
return date.toLocaleTimeString('en-US', {
|
|
82
|
+
timeZone: 'UTC',
|
|
83
|
+
hour12: false,
|
|
84
|
+
hour: '2-digit',
|
|
85
|
+
minute: '2-digit',
|
|
86
|
+
second: '2-digit'
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Handle numeric offset
|
|
91
|
+
const offset = parseFloat(timezone);
|
|
92
|
+
if (!isNaN(offset)) {
|
|
93
|
+
// Create a new date shifted by the offset hours
|
|
94
|
+
// We use UTC for display to avoid local system timezone interference
|
|
95
|
+
const shiftedDate = new Date(date.getTime() + (offset * 60 * 60 * 1000));
|
|
96
|
+
return shiftedDate.toLocaleTimeString('en-US', {
|
|
97
|
+
timeZone: 'UTC',
|
|
98
|
+
hour12: false,
|
|
99
|
+
hour: '2-digit',
|
|
100
|
+
minute: '2-digit',
|
|
101
|
+
second: '2-digit'
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Fallback to UTC
|
|
106
|
+
return date.toLocaleTimeString('en-US', {
|
|
107
|
+
timeZone: 'UTC',
|
|
108
|
+
hour12: false,
|
|
109
|
+
hour: '2-digit',
|
|
110
|
+
minute: '2-digit',
|
|
111
|
+
second: '2-digit'
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create a deduplication key from message hash and signature
|
|
117
|
+
* @param {string} hash - Message hash
|
|
118
|
+
* @param {string} signature - Message signature
|
|
119
|
+
* @returns {string} Deduplication key
|
|
120
|
+
*/
|
|
121
|
+
export function createMessageKey(hash, signature) {
|
|
122
|
+
return `${hash}|${signature}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read password from stdin with character masking
|
|
127
|
+
* @param {string} prompt - Prompt to display
|
|
128
|
+
* @param {string} [maskChar='*'] - Character to display for each typed character
|
|
129
|
+
* @returns {Promise<string>} The entered password
|
|
130
|
+
*/
|
|
131
|
+
export function readPassword(prompt, maskChar = '*') {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
process.stdout.write(prompt);
|
|
134
|
+
const stdin = process.stdin;
|
|
135
|
+
stdin.setRawMode(true);
|
|
136
|
+
stdin.resume();
|
|
137
|
+
stdin.setEncoding('utf8');
|
|
138
|
+
let password = '';
|
|
139
|
+
|
|
140
|
+
const onData = (char) => {
|
|
141
|
+
switch (char) {
|
|
142
|
+
case KEY_CODES.ENTER:
|
|
143
|
+
case KEY_CODES.CARRIAGE_RETURN:
|
|
144
|
+
case KEY_CODES.CTRL_D:
|
|
145
|
+
stdin.setRawMode(false);
|
|
146
|
+
stdin.pause();
|
|
147
|
+
stdin.removeListener('data', onData);
|
|
148
|
+
process.stdout.write('\n');
|
|
149
|
+
resolve(password);
|
|
150
|
+
break;
|
|
151
|
+
|
|
152
|
+
case KEY_CODES.CTRL_C:
|
|
153
|
+
process.stdout.write('\n');
|
|
154
|
+
process.exit(0);
|
|
155
|
+
break;
|
|
156
|
+
|
|
157
|
+
case KEY_CODES.BACKSPACE:
|
|
158
|
+
case KEY_CODES.BACKSPACE_ALT:
|
|
159
|
+
if (password.length > 0) {
|
|
160
|
+
password = password.slice(0, -1);
|
|
161
|
+
process.stdout.write(TERMINAL.BACKSPACE);
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
|
|
165
|
+
default:
|
|
166
|
+
password += char;
|
|
167
|
+
process.stdout.write(maskChar);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
stdin.on('data', onData);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Suppress all console output during a function execution
|
|
178
|
+
* @param {Function} fn - Function to execute with suppressed console
|
|
179
|
+
* @returns {Promise<*>} Result of the function
|
|
180
|
+
*/
|
|
181
|
+
export async function withSuppressedConsole(fn) {
|
|
182
|
+
const originalLog = console.log;
|
|
183
|
+
const originalWarn = console.warn;
|
|
184
|
+
const originalError = console.error;
|
|
185
|
+
const originalInfo = console.info;
|
|
186
|
+
const originalStdoutWrite = process.stdout.write;
|
|
187
|
+
const originalStderrWrite = process.stderr.write;
|
|
188
|
+
|
|
189
|
+
console.log = () => {};
|
|
190
|
+
console.warn = () => {};
|
|
191
|
+
console.error = () => {};
|
|
192
|
+
console.info = () => {};
|
|
193
|
+
process.stdout.write = () => {};
|
|
194
|
+
process.stderr.write = () => {};
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
return await fn();
|
|
198
|
+
} finally {
|
|
199
|
+
console.log = originalLog;
|
|
200
|
+
console.warn = originalWarn;
|
|
201
|
+
console.error = originalError;
|
|
202
|
+
console.info = originalInfo;
|
|
203
|
+
process.stdout.write = originalStdoutWrite;
|
|
204
|
+
process.stderr.write = originalStderrWrite;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Reset terminal to normal state
|
|
210
|
+
* Useful for cleanup on exit
|
|
211
|
+
*/
|
|
212
|
+
export function resetTerminal() {
|
|
213
|
+
if (process.stdout.isTTY) {
|
|
214
|
+
try {
|
|
215
|
+
process.stdout.write(TERMINAL.EXIT_ALT_SCREEN);
|
|
216
|
+
process.stdout.write(TERMINAL.SHOW_CURSOR);
|
|
217
|
+
process.stdout.write(TERMINAL.RESET_ATTRIBUTES);
|
|
218
|
+
process.stdout.write(TERMINAL.NEW_LINE);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
// Ignore errors during terminal reset
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (process.stdin.isTTY) {
|
|
225
|
+
try {
|
|
226
|
+
process.stdin.setRawMode(false);
|
|
227
|
+
process.stdin.pause();
|
|
228
|
+
} catch (err) {
|
|
229
|
+
// Ignore errors during stdin reset
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Parse RPC host from URL for display
|
|
236
|
+
* @param {string} url - Full RPC URL
|
|
237
|
+
* @returns {string} Hostname:port
|
|
238
|
+
*/
|
|
239
|
+
export function parseRpcHost(url) {
|
|
240
|
+
try {
|
|
241
|
+
const urlObj = new URL(url);
|
|
242
|
+
return urlObj.host;
|
|
243
|
+
} catch {
|
|
244
|
+
return url;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Validate password meets requirements
|
|
250
|
+
* @param {string} password - Password to validate
|
|
251
|
+
* @param {number} minLength - Minimum length
|
|
252
|
+
* @param {number} maxLength - Maximum length
|
|
253
|
+
* @returns {{valid: boolean, error: string|null}} Validation result
|
|
254
|
+
*/
|
|
255
|
+
export function validatePassword(password, minLength, maxLength) {
|
|
256
|
+
if (!password || password.length < minLength) {
|
|
257
|
+
return {
|
|
258
|
+
valid: false,
|
|
259
|
+
error: `Password must be at least ${minLength} characters`
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (password.length > maxLength) {
|
|
264
|
+
return {
|
|
265
|
+
valid: false,
|
|
266
|
+
error: `Password must be at most ${maxLength} characters`
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { valid: true, error: null };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Check if a public key has been revealed
|
|
275
|
+
* @param {Object} pubkeyResponse - Response from getpubkey RPC call
|
|
276
|
+
* @returns {boolean} True if pubkey is revealed and valid
|
|
277
|
+
*/
|
|
278
|
+
export function isPubkeyRevealed(pubkeyResponse) {
|
|
279
|
+
return pubkeyResponse?.pubkey &&
|
|
280
|
+
pubkeyResponse?.revealed === 1 &&
|
|
281
|
+
pubkeyResponse.pubkey.trim().length > 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Normalize public key to lowercase hex
|
|
286
|
+
* @param {string} pubkey - Public key to normalize
|
|
287
|
+
* @returns {string} Normalized public key
|
|
288
|
+
*/
|
|
289
|
+
export function normalizePubkey(pubkey) {
|
|
290
|
+
return pubkey.trim().toLowerCase();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Check if server has privacy layer enabled
|
|
295
|
+
* @param {Object} msgInfo - Response from depingetmsginfo
|
|
296
|
+
* @returns {boolean} True if privacy layer is enabled
|
|
297
|
+
*/
|
|
298
|
+
export function hasPrivacyLayer(msgInfo) {
|
|
299
|
+
return msgInfo?.depinpoolpkey && msgInfo.depinpoolpkey !== '0';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Check if response indicates encrypted privacy layer
|
|
304
|
+
* @param {Object} response - RPC response
|
|
305
|
+
* @returns {boolean} True if response is encrypted
|
|
306
|
+
*/
|
|
307
|
+
export function isEncryptedResponse(response) {
|
|
308
|
+
return Boolean(response?.encrypted);
|
|
309
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wallet manager for Neurai DePIN Terminal
|
|
3
|
+
* Handles wallet initialization and key derivation
|
|
4
|
+
* @module WalletManager
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import NeuraiKey from '@neuraiproject/neurai-key';
|
|
8
|
+
import secp256k1 from 'secp256k1';
|
|
9
|
+
import {
|
|
10
|
+
ADDRESS,
|
|
11
|
+
ERROR_MESSAGES
|
|
12
|
+
} from '../constants.js';
|
|
13
|
+
import { WalletError } from '../errors.js';
|
|
14
|
+
import { withSuppressedConsole } from '../utils.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Manages wallet operations
|
|
18
|
+
*/
|
|
19
|
+
export class WalletManager {
|
|
20
|
+
/**
|
|
21
|
+
* Create a new WalletManager instance
|
|
22
|
+
* @param {Object} config - Configuration object
|
|
23
|
+
* @param {string} config.network - Network name (xna)
|
|
24
|
+
* @param {string} config.privateKey - WIF private key
|
|
25
|
+
*/
|
|
26
|
+
constructor(config) {
|
|
27
|
+
this.config = config;
|
|
28
|
+
this.address = null;
|
|
29
|
+
this.publicKey = null;
|
|
30
|
+
this.privateKeyHex = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Derive address and public key from WIF private key
|
|
35
|
+
* Uses NeuraiKey to get address and secp256k1 to derive compressed public key
|
|
36
|
+
* @returns {Promise<void>}
|
|
37
|
+
* @throws {WalletError} If key derivation fails
|
|
38
|
+
*/
|
|
39
|
+
async deriveKeysFromWif() {
|
|
40
|
+
try {
|
|
41
|
+
// Suppress NeuraiKey console output
|
|
42
|
+
const keyInfo = await withSuppressedConsole(() => {
|
|
43
|
+
return NeuraiKey.getAddressByWIF(this.config.network, this.config.privateKey);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
this.address = keyInfo.address;
|
|
47
|
+
this.privateKeyHex = keyInfo.privateKey;
|
|
48
|
+
|
|
49
|
+
// Derive compressed public key from private key
|
|
50
|
+
const privateKeyBuffer = Buffer.from(keyInfo.privateKey, 'hex');
|
|
51
|
+
const publicKeyBuffer = secp256k1.publicKeyCreate(privateKeyBuffer, true);
|
|
52
|
+
this.publicKey = Buffer.from(publicKeyBuffer).toString('hex');
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new WalletError(`${ERROR_MESSAGES.INVALID_WIF}: ${error.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Initialize wallet: derive keys
|
|
60
|
+
* @returns {Promise<void>}
|
|
61
|
+
* @throws {WalletError} If key derivation fails
|
|
62
|
+
*/
|
|
63
|
+
async initialize() {
|
|
64
|
+
// Derive keys from WIF (fatal if fails)
|
|
65
|
+
await this.deriveKeysFromWif();
|
|
66
|
+
|
|
67
|
+
console.log(`DePIN Address: ${this.address}`);
|
|
68
|
+
console.log(`Public Key: ${this.publicKey.slice(0, ADDRESS.PUBKEY_DISPLAY_LENGTH)}...`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get wallet address
|
|
73
|
+
* @returns {string} Neurai address
|
|
74
|
+
*/
|
|
75
|
+
getAddress() {
|
|
76
|
+
return this.address;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get public key
|
|
81
|
+
* @returns {string} Compressed public key in hex format
|
|
82
|
+
*/
|
|
83
|
+
getPublicKey() {
|
|
84
|
+
return this.publicKey;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get private key in hex format
|
|
89
|
+
* @returns {string} Private key in hex format
|
|
90
|
+
*/
|
|
91
|
+
getPrivateKeyHex() {
|
|
92
|
+
return this.privateKeyHex;
|
|
93
|
+
}
|
|
94
|
+
}
|