@taqueria/plugin-tezbox 0.61.4

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/proxy.ts ADDED
@@ -0,0 +1,1129 @@
1
+ import {
2
+ execCmd,
3
+ getArch,
4
+ getDockerImage,
5
+ SandboxAccountConfig,
6
+ SandboxConfig,
7
+ sendAsyncErr,
8
+ sendAsyncJsonRes,
9
+ sendAsyncRes,
10
+ } from '@taqueria/node-sdk';
11
+ import { generateSecretKey, InMemorySigner } from '@taquito/signer';
12
+ import BigNumber from 'bignumber.js';
13
+ import * as bip39 from 'bip39';
14
+ import { createHash } from 'crypto';
15
+ import { create } from 'domain';
16
+ import * as fs from 'fs';
17
+ import * as hjson from 'hjson';
18
+ import * as path from 'path';
19
+ import { getDefaultDockerImage } from './docker';
20
+ import { Opts } from './types';
21
+
22
+ type ConfigV1Environment = {
23
+ sandboxes?: string[];
24
+ };
25
+
26
+ type Mutez = string | number; // Represents balance in mutez
27
+
28
+ type InstantiatedAccount = Omit<SandboxAccountConfig, 'encryptedKey'> & {
29
+ encryptedKey?: string;
30
+ };
31
+
32
+ interface TezboxAccount {
33
+ pkh: string;
34
+ pk: string;
35
+ sk: string;
36
+ balance: string;
37
+ }
38
+
39
+ type SandboxConfigV1 = SandboxConfig;
40
+
41
+ interface ProtocolMapping {
42
+ id: string; // e.g., "Proxford"
43
+ hash: string; // e.g., "PsDELPH1..."
44
+ }
45
+
46
+ interface DockerRunParams {
47
+ platform: string;
48
+ image: string;
49
+ containerName: string;
50
+ configDir: string;
51
+ dataDir: string;
52
+ port: number;
53
+ }
54
+
55
+ enum BakingOption {
56
+ ENABLED = 'enabled',
57
+ DISABLED = 'disabled',
58
+ }
59
+
60
+ /**
61
+ * Logger utility for standardized logging.
62
+ */
63
+ const logger = {
64
+ info: (message: string) => console.log(message),
65
+ warn: (message: string) => console.warn(message),
66
+ error: (message: string) => console.error(message),
67
+ };
68
+
69
+ /**
70
+ * Extracts error message from unknown error type and prepends a prefix.
71
+ /**
72
+ * Extracts error message from unknown error type and optionally prepends a prefix.
73
+ */
74
+ function getErrorMessage(prefix: string, error: unknown): string {
75
+ if (prefix === '') {
76
+ return error instanceof Error ? error.message : String(error);
77
+ }
78
+ if (typeof error === 'boolean') {
79
+ return `${prefix}:`;
80
+ }
81
+ const errorString = error instanceof Error ? error.message : String(error);
82
+ return `${prefix}: ${errorString}`;
83
+ }
84
+
85
+ /**
86
+ * Executes a shell command and standardizes error handling.
87
+ */
88
+ /**
89
+ * Executes a shell command and standardizes error handling.
90
+ */
91
+ async function runCommand(
92
+ cmd: string,
93
+ stderrHandler?: (stderr: string) => void | Promise<void>,
94
+ ): Promise<{ stdout: string }> {
95
+ // logger.info(`Executing command: ${cmd}`);
96
+ try {
97
+ const { stdout, stderr } = await execCmd(cmd);
98
+ if (stderr.trim()) {
99
+ if (stderrHandler) {
100
+ await stderrHandler(stderr.trim());
101
+ } else {
102
+ throw new Error(stderr.trim());
103
+ }
104
+ }
105
+ return { stdout };
106
+ } catch (error) {
107
+ throw new Error(getErrorMessage(`Command failed`, error));
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Checks if the given environment is configured for TezBox.
113
+ */
114
+ function isTezBoxEnvironment(taskArgs: Opts): boolean {
115
+ const environment = taskArgs.config.environment[taskArgs.env];
116
+ if (!environment || typeof environment !== 'object') return false;
117
+
118
+ const sandboxes = (environment as ConfigV1Environment).sandboxes;
119
+ if (!Array.isArray(sandboxes) || sandboxes.length === 0) return false;
120
+
121
+ // Currently, we don't have a way to tell if a sandbox is TezBox-provided
122
+ return true;
123
+ }
124
+
125
+ /**
126
+ * Throws warning indicating function is not implemented yet.
127
+ */
128
+ async function instantiateDeclaredAccount(
129
+ declaredAccountName: string,
130
+ balanceInMutez: BigNumber,
131
+ ): Promise<InstantiatedAccount> {
132
+ logger.warn(`instantiateDeclaredAccount is not implemented. Returning dummy data for ${declaredAccountName}.`);
133
+ // Return dummy data or default values
134
+ return {
135
+ encryptedKey: 'unencrypted:edpktdummykey123',
136
+ publicKeyHash: `tz1_dummy_public_key_hash_${declaredAccountName}`,
137
+ secretKey: 'unencrypted:edskdummysecretkey123',
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Throws warning indicating function is not implemented yet.
143
+ */
144
+ async function getInstantiatedAccounts(taskArgs: Opts): Promise<Record<string, InstantiatedAccount>> {
145
+ const sandboxConfig = getSandboxConfig(taskArgs);
146
+ if (!sandboxConfig.accounts) {
147
+ throw new Error('No instantiated accounts found in sandbox config.');
148
+ }
149
+ const accounts = sandboxConfig.accounts as Record<string, InstantiatedAccount>;
150
+ return Object.entries(accounts)
151
+ .filter(([key]) => key !== 'default')
152
+ .reduce((acc, [key, value]) => {
153
+ acc[key] = value;
154
+ return acc;
155
+ }, {} as Record<string, InstantiatedAccount>);
156
+ }
157
+
158
+ /**
159
+ * Gets the sandbox configuration for the given environment.
160
+ */
161
+ function getSandboxConfig(taskArgs: Opts): SandboxConfigV1 {
162
+ if (!isTezBoxEnvironment(taskArgs)) {
163
+ throw new Error(
164
+ `This configuration doesn't appear to be configured to use TezBox environments. Check the ${taskArgs.env} environment in your .taq/config.json.`,
165
+ );
166
+ }
167
+
168
+ const environment = taskArgs.config.environment[taskArgs.env] as ConfigV1Environment;
169
+ const sandboxName = environment.sandboxes?.[0];
170
+ if (sandboxName) {
171
+ const sandboxConfig = taskArgs.config.sandbox?.[sandboxName];
172
+ if (sandboxConfig) {
173
+ const retval: SandboxConfigV1 = {
174
+ blockTime: 1,
175
+ baking: BakingOption.ENABLED,
176
+ ...sandboxConfig,
177
+ ...sandboxConfig.annotations,
178
+ };
179
+
180
+ return retval;
181
+ }
182
+ }
183
+ throw new Error(`No sandbox configuration found for ${taskArgs.env} environment.`);
184
+ }
185
+
186
+ /**
187
+ * Gets or creates instantiated accounts.
188
+ */
189
+ async function getOrCreateInstantiatedAccounts(
190
+ taskArgs: Opts,
191
+ ): Promise<Record<string, InstantiatedAccount>> {
192
+ let instantiatedAccounts: Record<string, InstantiatedAccount>;
193
+
194
+ try {
195
+ // Attempt to get instantiated accounts
196
+ instantiatedAccounts = await getInstantiatedAccounts(taskArgs);
197
+ } catch (error) {
198
+ // No instantiated accounts available, so we need to instantiate them
199
+ instantiatedAccounts = {};
200
+ const declaredAccounts = taskArgs.config.accounts as Record<string, Mutez>;
201
+
202
+ for (const [accountName, balanceInMutez] of Object.entries(declaredAccounts)) {
203
+ // Convert balance to BigNumber, removing any underscores used for formatting
204
+ const balanceInMutezBN = new BigNumber(balanceInMutez.toString().replace(/_/g, ''));
205
+
206
+ // Instantiate the declared account
207
+ const instantiatedAccount = await instantiateDeclaredAccount(accountName, balanceInMutezBN);
208
+
209
+ // Store the instantiated account
210
+ instantiatedAccounts[accountName] = instantiatedAccount;
211
+ }
212
+
213
+ // Optionally, save the instantiated accounts to persist them for future runs
214
+ await saveInstantiatedAccounts(instantiatedAccounts, taskArgs.projectDir, taskArgs.env);
215
+ }
216
+
217
+ return instantiatedAccounts;
218
+ }
219
+
220
+ /**
221
+ * Saves instantiated accounts to the local configuration file.
222
+ * @param accounts Record of instantiated accounts
223
+ * @param projectDir Project directory path
224
+ * @param env Environment name
225
+ */
226
+ async function saveInstantiatedAccounts(
227
+ accounts: Record<string, InstantiatedAccount>,
228
+ projectDir: string,
229
+ env: string,
230
+ ): Promise<void> {
231
+ const configPath = path.join(projectDir, `.taq/config.local.${env}.json`);
232
+
233
+ try {
234
+ // Read existing config or use an empty object if file doesn't exist
235
+ let config: Record<string, any> = {};
236
+ try {
237
+ const configContent = await fs.promises.readFile(configPath, 'utf8');
238
+ config = JSON.parse(configContent);
239
+ } catch (error) {
240
+ // If file doesn't exist or there's an error reading it, we'll use an empty object
241
+ }
242
+
243
+ // Convert InstantiatedAccount to SandboxAccountConfig, omitting encryptedKey
244
+ const sandboxAccounts: Record<string, Omit<SandboxAccountConfig, 'encryptedKey'>> = {};
245
+ for (const [name, account] of Object.entries(accounts)) {
246
+ sandboxAccounts[name] = {
247
+ publicKeyHash: account.publicKeyHash,
248
+ secretKey: account.secretKey,
249
+ };
250
+ }
251
+
252
+ // Update only the accounts property in the config
253
+ config.accounts = sandboxAccounts;
254
+
255
+ // Ensure the directory exists
256
+ await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
257
+
258
+ // Write the updated config back to the file
259
+ await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
260
+ } catch (error) {
261
+ throw new Error(getErrorMessage('Failed to save instantiated accounts', error));
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Generates a mnemonic phrase using BIP39.
267
+ * @param strength The entropy bit length, defaults to 128 (resulting in a 12-word mnemonic).
268
+ * @returns A promise that resolves to the generated mnemonic phrase.
269
+ */
270
+ async function generateMnemonic(strength: number = 128): Promise<string> {
271
+ try {
272
+ const mnemonic = bip39.generateMnemonic(strength);
273
+ return mnemonic;
274
+ } catch (error) {
275
+ console.error('Error generating mnemonic:', error);
276
+ throw new Error('Failed to generate mnemonic');
277
+ }
278
+ }
279
+
280
+ // Function to generate a new implicit account
281
+ async function createNewAccount() {
282
+ const mnemonic = await generateMnemonic();
283
+
284
+ // Generate the BIP39 seed from the mnemonic
285
+ const seed = await bip39.mnemonicToSeed(mnemonic);
286
+
287
+ // Convert the seed (Buffer) to a UInt8Array
288
+ const seedUInt8Array = new Uint8Array(seed);
289
+
290
+ // Generate the secret key
291
+ const secretKey = generateSecretKey(seedUInt8Array, "m/44'/1729'/0'/0'", 'ed25519');
292
+
293
+ // Derive the public key and public key hash from the secret key
294
+ const signer = new InMemorySigner(secretKey);
295
+ const publicKey = await signer.publicKey();
296
+ const publicKeyHash = await signer.publicKeyHash();
297
+
298
+ // Return the object with pk, pkh, sk, and balance
299
+ return {
300
+ pk: publicKey,
301
+ pkh: publicKeyHash,
302
+ sk: `unencrypted:${secretKey}`,
303
+ };
304
+ }
305
+
306
+ function createFunderAccount() {
307
+ return createNewAccount().then(account => ({
308
+ publicKeyHash: account.pkh,
309
+ secretKey: account.sk,
310
+ }));
311
+ }
312
+
313
+ async function getPublicKeyFromSecretKey(secretKey: string) {
314
+ // Initialize the signer with the secret key
315
+ const signer = await InMemorySigner.fromSecretKey(secretKey.replace('unencrypted:', ''));
316
+
317
+ // Get the public key
318
+ const publicKey = await signer.publicKey();
319
+
320
+ return publicKey;
321
+ }
322
+
323
+ /**
324
+ * Prepares TezBox accounts configuration.
325
+ */
326
+ /**
327
+ * Prepares TezBox accounts configuration.
328
+ */
329
+ async function prepareTezBoxAccounts(
330
+ instantiatedAccounts: Record<string, InstantiatedAccount>,
331
+ declaredAccounts: Record<string, Mutez>,
332
+ ): Promise<Record<string, TezboxAccount>> {
333
+ // Add funder account to instantiatedAccounts
334
+ // instantiatedAccounts['funder'] = await createFunderAccount();
335
+
336
+ const tezboxAccounts: Record<string, TezboxAccount> = {};
337
+
338
+ for (const [accountName, accountData] of Object.entries(instantiatedAccounts)) {
339
+ if (accountName === 'default') continue;
340
+
341
+ const secretKey = accountData.secretKey;
342
+ tezboxAccounts[accountName] = {
343
+ pkh: accountData.publicKeyHash,
344
+ pk: await getPublicKeyFromSecretKey(secretKey),
345
+ sk: secretKey,
346
+ balance: accountName === 'funder'
347
+ ? new BigNumber(100000000000000).toString()
348
+ : new BigNumber(declaredAccounts[accountName].toString()).toString(),
349
+ };
350
+ }
351
+
352
+ return tezboxAccounts;
353
+ }
354
+
355
+ /**
356
+ * Writes accounts.hjson file.
357
+ */
358
+ async function writeAccountsHjson(
359
+ tezboxAccounts: Record<string, TezboxAccount>,
360
+ tezBoxConfigDir: string,
361
+ ): Promise<void> {
362
+ // TODO: Remove for debugging
363
+ await Promise.resolve();
364
+
365
+ // // Rearrange accounts record so that funder is first
366
+ // const funderAccount = tezboxAccounts['funder'];
367
+ // delete tezboxAccounts['funder'];
368
+ // tezboxAccounts = { funder: funderAccount, ...tezboxAccounts };
369
+
370
+ // Convert the accounts object to HJSON format
371
+ const hjsonContent = hjson.stringify(tezboxAccounts, {
372
+ quotes: 'min',
373
+ bracesSameLine: true,
374
+ separator: false,
375
+ });
376
+
377
+ // Remove quotes around sk values and ensure proper indentation
378
+ const fixedHjsonContent = hjsonContent.replaceAll('"', '');
379
+
380
+ // Write the accounts.hjson file
381
+ const accountsHjsonPath = path.join(tezBoxConfigDir, 'accounts.hjson');
382
+ await fs.promises.writeFile(accountsHjsonPath, fixedHjsonContent, 'utf8');
383
+ }
384
+
385
+ /**
386
+ * Gets the declared accounts from the task arguments and removes underscores from Mutez values.
387
+ */
388
+ function getDeclaredAccounts(taskArgs: Opts): Record<string, Mutez> {
389
+ const declaredAccounts = taskArgs.config.accounts as Record<string, Mutez>;
390
+ return Object.entries(declaredAccounts).reduce((acc, [key, value]) => {
391
+ acc[key] = value.toString().replace(/_/g, '');
392
+ return acc;
393
+ }, {} as Record<string, Mutez>);
394
+ }
395
+
396
+ /**
397
+ * Prepares accounts.hjson for TezBox.
398
+ */
399
+ async function prepareAccountsHjson(taskArgs: Opts, tezBoxConfigDir: string): Promise<void> {
400
+ try {
401
+ // Get or create instantiated accounts
402
+ const instantiatedAccounts = await getOrCreateInstantiatedAccounts(taskArgs);
403
+
404
+ // Retrieve declared accounts
405
+ const declaredAccounts = getDeclaredAccounts(taskArgs);
406
+
407
+ // Prepare tezbox accounts
408
+ const tezboxAccounts = await prepareTezBoxAccounts(instantiatedAccounts, declaredAccounts);
409
+
410
+ // Write the accounts.hjson file
411
+ await writeAccountsHjson(tezboxAccounts, tezBoxConfigDir);
412
+ } catch (error) {
413
+ throw new Error(getErrorMessage(`Failed to prepare accounts`, error));
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Prepares bakers.hjson for TezBox.
419
+ */
420
+ async function prepareBakersHjson(taskArgs: Opts, tezBoxConfigDir: string): Promise<void> {
421
+ try {
422
+ // Get declared accounts
423
+ const declaredAccounts = getDeclaredAccounts(taskArgs);
424
+
425
+ // Calculate total balance
426
+ const totalBalance = Object.values(declaredAccounts).reduce(
427
+ (sum, balance) => BigNumber.sum(sum, balance),
428
+ new BigNumber(0),
429
+ ).toString();
430
+
431
+ // Prepare bakers object
432
+ const bakers = {
433
+ baker1: {
434
+ pkh: 'tz1faswCTDciRzE4oJ9jn2Vm2dvjeyA9fUzU',
435
+ pk: 'edpkuTXkJDGcFd5nh6VvMz8phXxU3Bi7h6hqgywNFi1vZTfQNnS1RV',
436
+ sk: 'unencrypted:edsk4ArLQgBTLWG5FJmnGnT689VKoqhXwmDPBuGx3z4cvwU9MmrPZZ',
437
+ balance: totalBalance,
438
+ },
439
+ baker2: {
440
+ pkh: 'tz1gjaF81ZRRvdzjobyfVNsAeSC6PScjfQwN',
441
+ pk: 'edpktzNbDAUjUk697W7gYg2CRuBQjyPxbEg8dLccYYwKSKvkPvjtV9',
442
+ sk: 'unencrypted:edsk39qAm1fiMjgmPkw1EgQYkMzkJezLNewd7PLNHTkr6w9XA2zdfo',
443
+ balance: totalBalance,
444
+ },
445
+ baker3: {
446
+ pkh: 'tz1b7tUupMgCNw2cCLpKTkSD1NZzB5TkP2sv',
447
+ pk: 'edpkuFrRoDSEbJYgxRtLx2ps82UdaYc1WwfS9sE11yhauZt5DgCHbU',
448
+ sk: 'unencrypted:edsk2uqQB9AY4FvioK2YMdfmyMrer5R8mGFyuaLLFfSRo8EoyNdht3',
449
+ balance: totalBalance,
450
+ },
451
+ };
452
+
453
+ // Convert the bakers object to HJSON format
454
+ const hjsonContent = hjson.stringify(bakers, {
455
+ quotes: 'min',
456
+ bracesSameLine: true,
457
+ separator: false,
458
+ });
459
+
460
+ // Remove quotes around sk values and ensure proper indentation
461
+ const fixedHjsonContent = hjsonContent.replaceAll('"', '');
462
+
463
+ // Write the bakers.hjson file
464
+ const bakersHjsonPath = path.join(tezBoxConfigDir, 'bakers.hjson');
465
+ await fs.promises.writeFile(bakersHjsonPath, fixedHjsonContent, 'utf8');
466
+ } catch (error) {
467
+ throw new Error(getErrorMessage(`Failed to prepare bakers`, error));
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Generates a project ID based on the project directory.
473
+ */
474
+ function getProjectId(taskArgs: Opts): string {
475
+ return createHash('sha256').update(taskArgs.projectDir).digest('hex');
476
+ }
477
+
478
+ /**
479
+ * Gets the docker container name for the sandbox.
480
+ */
481
+ function getDockerContainerName(taskArgs: Opts): string {
482
+ const projectId = getProjectId(taskArgs);
483
+ return `taq-${taskArgs.env}-${projectId}`;
484
+ }
485
+
486
+ /**
487
+ * Gets the TezBox configuration directory.
488
+ */
489
+ function getTezBoxConfigDir(taskArgs: Opts): string {
490
+ const containerName = getDockerContainerName(taskArgs);
491
+ return path.join(taskArgs.projectDir, `.taq/.${containerName}/config`);
492
+ }
493
+
494
+ /**
495
+ * Gets the TezBox data directory.
496
+ */
497
+ function getTezBoxDataDir(taskArgs: Opts): string {
498
+ const containerName = getDockerContainerName(taskArgs);
499
+ return path.join(taskArgs.projectDir, `.taq/.${containerName}/data`);
500
+ }
501
+
502
+ /**
503
+ * Gets the docker image for TezBox.
504
+ */
505
+ function getImage(taskArgs: Opts): string {
506
+ return getDockerImage(getDefaultDockerImage(taskArgs), 'TAQ_TEZBOX_IMAGE');
507
+ }
508
+
509
+ /**
510
+ * Checks if the sandbox is running.
511
+ */
512
+ async function isSandboxRunning(taskArgs: Opts): Promise<boolean> {
513
+ const containerName = getDockerContainerName(taskArgs);
514
+ const cmd = `docker ps --filter "name=${containerName}" --format "{{.ID}}"`;
515
+ const { stdout } = await runCommand(cmd);
516
+ return stdout.trim() !== '';
517
+ }
518
+
519
+ /**
520
+ * Checks if the sandbox is already running.
521
+ */
522
+ async function checkSandboxRunning(taskArgs: Opts): Promise<boolean> {
523
+ const running = await isSandboxRunning(taskArgs);
524
+ if (running) {
525
+ await sendAsyncRes('Sandbox is already running.');
526
+ }
527
+ return running;
528
+ }
529
+
530
+ function getPortNumber(taskArgs: Opts) {
531
+ const rpcUrl = getSandboxConfig(taskArgs).rpcUrl;
532
+ const match = rpcUrl.match(/:(\d+)/);
533
+ return match ? parseInt(match[1], 10) : 80;
534
+ }
535
+
536
+ function getContainerPort(taskArgs: Opts) {
537
+ return getPortNumber(taskArgs) + 1;
538
+ }
539
+
540
+ /**
541
+ * Gets the docker run parameters.
542
+ */
543
+ async function getDockerRunParams(taskArgs: Opts): Promise<DockerRunParams> {
544
+ const image = getImage(taskArgs);
545
+ const containerName = getDockerContainerName(taskArgs);
546
+ const platform = await getArch();
547
+ const configDir = getTezBoxConfigDir(taskArgs);
548
+ const dataDir = getTezBoxDataDir(taskArgs);
549
+ const port = getContainerPort(taskArgs);
550
+
551
+ return { platform, image, containerName, configDir, dataDir, port };
552
+ }
553
+
554
+ /**
555
+ * Ensures directories exist.
556
+ */
557
+ async function ensureDirectoriesExist(directories: string[]): Promise<void> {
558
+ await Promise.all(
559
+ directories.map(dir => fs.promises.mkdir(dir, { recursive: true })),
560
+ );
561
+ }
562
+
563
+ /**
564
+ * Constructs the docker run command.
565
+ */
566
+ function constructDockerRunCommand(params: DockerRunParams): string {
567
+ const { platform, image, containerName, configDir, port } = params;
568
+
569
+ const dockerOptions = [
570
+ 'docker run',
571
+ '-d',
572
+ `--platform ${platform}`,
573
+ '-p 8732:8732',
574
+ `-p ${port}:20000`,
575
+ `--name ${containerName}`,
576
+ `-v "${configDir}:/tezbox/overrides"`,
577
+ image,
578
+ // 'qenabox', // TODO: restore once working upstream
579
+ ];
580
+
581
+ return dockerOptions.join(' ');
582
+ }
583
+
584
+ /**
585
+ * Validates the block time in the sandbox configuration.
586
+ */
587
+ function validateBlockTime(taskArgs: Opts): number | null {
588
+ const sandboxConfig = getSandboxConfig(taskArgs);
589
+ const blockTime = sandboxConfig.blockTime;
590
+ if (blockTime === undefined || blockTime === null) {
591
+ logger.warn('Block time is not specified; skipping block_time override.');
592
+ return null;
593
+ }
594
+ return blockTime;
595
+ }
596
+
597
+ /**
598
+ * Writes sandbox parameters for a single protocol.
599
+ */
600
+ async function writeSandboxParameters(
601
+ protocolId: string,
602
+ parameters: Record<string, string | number>,
603
+ tezBoxConfigDir: string,
604
+ ): Promise<void> {
605
+ const protocolsDir = path.join(tezBoxConfigDir, 'protocols', protocolId);
606
+ await fs.promises.mkdir(protocolsDir, { recursive: true });
607
+
608
+ const hjsonContent = hjson.stringify(parameters, {
609
+ quotes: 'min',
610
+ bracesSameLine: true,
611
+ separator: false,
612
+ });
613
+
614
+ const sandboxParamsPath = path.join(protocolsDir, 'sandbox-parameters.hjson');
615
+ await fs.promises.writeFile(sandboxParamsPath, hjsonContent, 'utf8');
616
+
617
+ // Ensure the file has write permissions
618
+ try {
619
+ await fs.promises.chmod(sandboxParamsPath, 0o644);
620
+ } catch (error) {
621
+ logger.warn(getErrorMessage(`Failed to set file permissions for ${sandboxParamsPath}`, error));
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Applies block time override to a single protocol.
627
+ */
628
+ async function applyBlockTimeOverrideToProtocol(
629
+ protocolId: string,
630
+ blockTime: number,
631
+ tezBoxConfigDir: string,
632
+ ): Promise<void> {
633
+ const nonce_revelation_threshold = 16;
634
+ const minimal_block_delay = blockTime;
635
+
636
+ const parameters = {
637
+ minimal_block_delay: minimal_block_delay.toString(),
638
+ };
639
+ await writeSandboxParameters(protocolId, parameters, tezBoxConfigDir);
640
+ }
641
+
642
+ /**
643
+ * Applies block time override to multiple protocols.
644
+ */
645
+ async function applyBlockTimeOverrideToProtocols(
646
+ protocolIds: string[],
647
+ blockTime: number,
648
+ tezBoxConfigDir: string,
649
+ ): Promise<void> {
650
+ await Promise.all(
651
+ protocolIds.map(async protocolId => {
652
+ // Skip alpha protocol as it's a placeholder
653
+ if (/^alpha$/i.test(protocolId)) {
654
+ return;
655
+ }
656
+ await applyBlockTimeOverrideToProtocol(protocolId, blockTime, tezBoxConfigDir);
657
+ }),
658
+ );
659
+ }
660
+
661
+ /**
662
+ * Gets protocol identifiers from TezBox configuration.
663
+ */
664
+ async function getProtocolIds(taskArgs: Opts): Promise<string[]> {
665
+ const image = getImage(taskArgs);
666
+
667
+ // List the protocol directories inside the TezBox image
668
+ const cmd = `docker run --rm --entrypoint ls ${image} /tezbox/configuration/protocols`;
669
+ const { stdout } = await runCommand(cmd);
670
+
671
+ const protocolIds = stdout
672
+ .trim()
673
+ .split('\n')
674
+ .map(line => line.trim())
675
+ .filter(line => line !== '');
676
+
677
+ return protocolIds;
678
+ }
679
+
680
+ /**
681
+ * Reads and parses protocol.hjson for a given protocolId.
682
+ */
683
+ async function readProtocolJson(image: string, protocolId: string): Promise<ProtocolMapping | null> {
684
+ const cmd = `docker run --rm --entrypoint cat ${image} /tezbox/configuration/protocols/${protocolId}/protocol.hjson`;
685
+
686
+ try {
687
+ const { stdout } = await runCommand(cmd);
688
+
689
+ if (!stdout.trim()) {
690
+ logger.warn(`protocol.hjson not found or empty for protocolId ${protocolId}; skipping this protocol.`);
691
+ return null;
692
+ }
693
+
694
+ // Parse the HJSON content
695
+ const protocolData = hjson.parse(stdout);
696
+ const protocolHash: string = protocolData.hash;
697
+ if (protocolHash) {
698
+ return { id: protocolId, hash: protocolHash };
699
+ } else {
700
+ logger.warn(`Protocol hash not found in protocol.hjson for protocolId ${protocolId}; skipping.`);
701
+ return null;
702
+ }
703
+ } catch (error) {
704
+ logger.warn(getErrorMessage(`Failed to read protocol.hjson for protocolId ${protocolId}`, error));
705
+ return null;
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Gets protocol mappings.
711
+ */
712
+ async function getProtocolMappings(taskArgs: Opts): Promise<ProtocolMapping[]> {
713
+ const image = getImage(taskArgs);
714
+ const protocolIds = await getProtocolIds(taskArgs);
715
+
716
+ const protocolMappingsPromises = protocolIds.map(async protocolId => {
717
+ const mapping = await readProtocolJson(image, protocolId);
718
+ return mapping;
719
+ });
720
+
721
+ const protocolMappings = await Promise.all(protocolMappingsPromises);
722
+ return protocolMappings.filter((mapping): mapping is ProtocolMapping => mapping !== null);
723
+ }
724
+
725
+ /**
726
+ * Gets protocol hashes from octez-client.
727
+ */
728
+ async function getOctezClientProtocols(taskArgs: Opts): Promise<string[]> {
729
+ const image = getImage(taskArgs);
730
+ const cmd = `docker run --rm --entrypoint octez-client ${image} -M mockup list mockup protocols`;
731
+ const { stdout } = await runCommand(cmd, stderr => {
732
+ const ignorableError = 'Base directory /tezbox/data/.tezos-client does not exist.';
733
+
734
+ if (stderr.trim() !== '' && !stderr.includes(ignorableError)) {
735
+ throw new Error(`Failed to list protocols: ${stderr.trim()}`);
736
+ }
737
+ });
738
+
739
+ const protocols = stdout
740
+ .trim()
741
+ .split('\n')
742
+ .filter(line => line.trim() !== '');
743
+
744
+ return protocols;
745
+ }
746
+
747
+ /**
748
+ * Prepares sandbox-parameters.hjson for block_time override.
749
+ */
750
+ async function prepareSandboxParametersHjson(taskArgs: Opts, tezBoxConfigDir: string): Promise<void> {
751
+ try {
752
+ // Validate block time
753
+ const blockTime = validateBlockTime(taskArgs);
754
+ if (blockTime === null) {
755
+ return;
756
+ }
757
+
758
+ // Get the protocol mappings from TezBox
759
+ const protocolMappings = await getProtocolMappings(taskArgs);
760
+ const hashToIdMap: Record<string, string> = {};
761
+ for (const mapping of protocolMappings) {
762
+ hashToIdMap[mapping.hash] = mapping.id;
763
+ }
764
+
765
+ // Get the list of protocol hashes supported by octez-client
766
+ const protocolHashes = await getOctezClientProtocols(taskArgs);
767
+
768
+ // Map protocol hashes to TezBox protocol IDs
769
+ const protocolIdsToOverride = protocolHashes
770
+ .map(hash => hashToIdMap[hash])
771
+ .filter((id): id is string => id !== undefined);
772
+
773
+ if (protocolIdsToOverride.length === 0) {
774
+ logger.warn('No matching protocol IDs found; cannot set block_time override.');
775
+ return;
776
+ }
777
+
778
+ // Debug: Log the protocol IDs to override
779
+ // logger.info(`Protocol IDs to override: ${protocolIdsToOverride.join(', ')}`);
780
+
781
+ // Apply block time override to each protocol ID
782
+ await applyBlockTimeOverrideToProtocols(protocolIdsToOverride, blockTime, tezBoxConfigDir);
783
+ } catch (error) {
784
+ const errorMessage = getErrorMessage(`Failed to prepare sandbox parameters:`, error);
785
+ throw new Error(errorMessage);
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Prepares baker.hjson if baking is disabled.
791
+ */
792
+ async function prepareBakerHjson(tezBoxConfigDir: string): Promise<void> {
793
+ const servicesDir = path.join(tezBoxConfigDir, 'services');
794
+ try {
795
+ await fs.promises.mkdir(servicesDir, { recursive: true });
796
+
797
+ const bakerConfig = {
798
+ autostart: false,
799
+ };
800
+
801
+ const hjsonContent = hjson.stringify(bakerConfig, {
802
+ quotes: 'all',
803
+ bracesSameLine: true,
804
+ separator: true,
805
+ });
806
+
807
+ const bakerConfigPath = path.join(servicesDir, 'baker.hjson');
808
+ await fs.promises.writeFile(bakerConfigPath, hjsonContent, 'utf8');
809
+ } catch (error) {
810
+ throw new Error(getErrorMessage(`Failed to prepare baker.hjson`, error));
811
+ }
812
+ }
813
+
814
+ /**
815
+ * Prepares TezBox configuration overrides.
816
+ */
817
+ async function prepareTezBoxOverrides(taskArgs: Opts): Promise<void> {
818
+ const tezBoxConfigDir = getTezBoxConfigDir(taskArgs);
819
+
820
+ try {
821
+ // Get sandbox configuration
822
+ const sandboxConfig = getSandboxConfig(taskArgs);
823
+
824
+ // Ensure the configuration directory exists
825
+ await fs.promises.mkdir(tezBoxConfigDir, { recursive: true });
826
+
827
+ // Prepare tasks
828
+ const tasks: Promise<void>[] = [];
829
+
830
+ // Prepare bakers.hjson
831
+ tasks.push(prepareBakersHjson(taskArgs, tezBoxConfigDir));
832
+
833
+ // Prepare accounts.hjson
834
+ if (taskArgs.config.accounts) {
835
+ tasks.push(prepareAccountsHjson(taskArgs, tezBoxConfigDir));
836
+ }
837
+
838
+ // Prepare sandbox-parameters.hjson for block_time
839
+ if (sandboxConfig.blockTime) {
840
+ tasks.push(prepareSandboxParametersHjson(taskArgs, tezBoxConfigDir));
841
+ }
842
+
843
+ // Prepare baker.hjson if baking is disabled
844
+ if (sandboxConfig.baking === BakingOption.DISABLED) {
845
+ tasks.push(prepareBakerHjson(tezBoxConfigDir));
846
+ }
847
+
848
+ // Run all preparations in parallel
849
+ await Promise.all(tasks);
850
+ } catch (error) {
851
+ throw new Error(getErrorMessage(`Failed to prepare TezBox overrides`, error));
852
+ }
853
+ }
854
+
855
+ function getProxyContainerName(taskArgs: Opts) {
856
+ return `${getDockerContainerName(taskArgs)}-proxy`;
857
+ }
858
+
859
+ async function startProxyServer(taskArgs: Opts): Promise<void> {
860
+ const containerPort = getContainerPort(taskArgs);
861
+ const proxyPort = getPortNumber(taskArgs);
862
+ const proxyContainerName = getProxyContainerName(taskArgs);
863
+
864
+ const proxyCmd = `docker run -d --name ${proxyContainerName} \
865
+ --network host \
866
+ caddy:2-alpine \
867
+ caddy reverse-proxy \
868
+ --from http://:${proxyPort} \
869
+ --to http://127.0.0.1:${containerPort} \
870
+ --access-log`;
871
+
872
+ try {
873
+ await runCommand(proxyCmd);
874
+ } catch (error) {
875
+ throw new Error(getErrorMessage(`Failed to start Caddy reverse proxy`, error));
876
+ }
877
+ }
878
+
879
+ async function stopProxyServer(taskArgs: Opts): Promise<void> {
880
+ const proxyContainerName = getProxyContainerName(taskArgs);
881
+ const cmd = `docker rm -f ${proxyContainerName}`;
882
+ await runCommand(cmd);
883
+ }
884
+
885
+ /**
886
+ * Starts the sandbox.
887
+ */
888
+ async function startSandbox(taskArgs: Opts): Promise<void> {
889
+ try {
890
+ // Check for Docker availability
891
+ await checkDockerAvailability();
892
+
893
+ // Check if the sandbox is already running
894
+ if (await checkSandboxRunning(taskArgs)) {
895
+ return;
896
+ }
897
+
898
+ // Get Docker run parameters
899
+ const params = await getDockerRunParams(taskArgs);
900
+
901
+ // Ensure necessary directories exist
902
+ await ensureDirectoriesExist([params.dataDir, params.configDir]);
903
+
904
+ // Prepare TezBox configuration overrides
905
+ await prepareTezBoxOverrides(taskArgs);
906
+
907
+ // Construct the Docker run command
908
+ const cmd = constructDockerRunCommand(params);
909
+ // logger.info(`Starting sandbox with command: ${cmd}`);
910
+
911
+ // Execute the Docker run command
912
+ await runCommand(cmd);
913
+
914
+ // Start the proxy server
915
+ await startProxyServer(taskArgs);
916
+
917
+ // Send a success response
918
+ await sendAsyncRes('Sandbox started successfully.');
919
+ } catch (error) {
920
+ await sendAsyncErr(getErrorMessage(`Failed to start sandbox`, error));
921
+ }
922
+ }
923
+
924
+ /**
925
+ * Checks for Docker availability.
926
+ */
927
+ async function checkDockerAvailability(): Promise<void> {
928
+ try {
929
+ await runCommand('docker --version');
930
+ } catch (error) {
931
+ throw new Error('Docker is not installed or not running. Please install and start Docker.');
932
+ }
933
+ }
934
+
935
+ /**
936
+ * Removes the sandbox container.
937
+ */
938
+ async function removeSandboxContainer(taskArgs: Opts): Promise<void> {
939
+ const containerName = getDockerContainerName(taskArgs);
940
+ const cmd = `docker rm -f ${containerName}`;
941
+
942
+ try {
943
+ await runCommand(cmd);
944
+ } catch (error) {
945
+ const errorMessage = getErrorMessage('', error);
946
+ if (errorMessage.includes('No such container')) {
947
+ // Container does not exist
948
+ await sendAsyncRes('Sandbox is not running or already stopped.');
949
+ } else {
950
+ throw new Error(errorMessage);
951
+ }
952
+ }
953
+
954
+ // Stop the proxy server
955
+ await stopProxyServer(taskArgs);
956
+ }
957
+
958
+ /**
959
+ * Stops the sandbox.
960
+ */
961
+ async function stopSandbox(taskArgs: Opts): Promise<void> {
962
+ try {
963
+ // Attempt to stop and remove the sandbox container
964
+ await removeSandboxContainer(taskArgs);
965
+
966
+ // Optionally, clean up configuration directory if needed
967
+ const configDir = getTezBoxConfigDir(taskArgs);
968
+ await fs.promises.rm(configDir, { recursive: true, force: true });
969
+
970
+ // Send a success response
971
+ await sendAsyncRes('Sandbox stopped and cleaned up.');
972
+ } catch (error) {
973
+ await sendAsyncErr(getErrorMessage(`Failed to stop sandbox`, error));
974
+ }
975
+ }
976
+
977
+ /**
978
+ * Restarts the sandbox.
979
+ */
980
+ async function restartSandbox(taskArgs: Opts): Promise<void> {
981
+ try {
982
+ // Stop the sandbox if it's running
983
+ await removeSandboxContainer(taskArgs);
984
+
985
+ // Start the sandbox
986
+ await startSandbox(taskArgs);
987
+
988
+ // Send a success response
989
+ await sendAsyncRes('Sandbox restarted successfully.');
990
+ } catch (error) {
991
+ await sendAsyncErr(getErrorMessage(`Failed to restart sandbox`, error));
992
+ }
993
+ }
994
+
995
+ /**
996
+ * Lists protocols.
997
+ */
998
+ async function listProtocols(taskArgs: Opts): Promise<void> {
999
+ try {
1000
+ const protocolHashes = await getOctezClientProtocols(taskArgs);
1001
+ const protocolObjects = protocolHashes.map(protocol => ({ protocol }));
1002
+ await sendAsyncJsonRes(protocolObjects);
1003
+ } catch (error) {
1004
+ await sendAsyncErr(getErrorMessage(`Failed to list protocols`, error));
1005
+ }
1006
+ }
1007
+
1008
+ /**
1009
+ * Lists accounts.
1010
+ */
1011
+ async function listAccounts(taskArgs: Opts): Promise<void> {
1012
+ try {
1013
+ if (await isSandboxRunning(taskArgs)) {
1014
+ // List accounts from the sandbox
1015
+ const containerName = getDockerContainerName(taskArgs);
1016
+ const cmd = `docker exec ${containerName} octez-client list known addresses`;
1017
+ const { stdout } = await runCommand(cmd);
1018
+
1019
+ if (!stdout.trim()) {
1020
+ await sendAsyncRes('No accounts found.');
1021
+ return;
1022
+ }
1023
+
1024
+ const accounts = stdout
1025
+ .trim()
1026
+ .split('\n')
1027
+ .filter(line => line.trim() !== '')
1028
+ .map(line => {
1029
+ const [name, rest] = line.split(':');
1030
+ const address = rest ? rest.trim().split(' ')[0] : '';
1031
+ return { name: name.trim(), address };
1032
+ });
1033
+
1034
+ await sendAsyncJsonRes(accounts);
1035
+ } else {
1036
+ await sendAsyncErr(`Sandbox is not running. Please start the sandbox before attempting to list accounts.`);
1037
+ }
1038
+ } catch (error) {
1039
+ await sendAsyncErr(getErrorMessage(`Failed to list accounts`, error));
1040
+ }
1041
+ }
1042
+
1043
+ /**
1044
+ * Bakes a block in the sandbox.
1045
+ */
1046
+ async function bakeBlock(taskArgs: Opts): Promise<void> {
1047
+ try {
1048
+ if (await isSandboxRunning(taskArgs)) {
1049
+ const containerName = getDockerContainerName(taskArgs);
1050
+ const cmd = `docker exec ${containerName} octez-client bake for baker1`;
1051
+
1052
+ if (taskArgs.watch) {
1053
+ console.log('Baking on demand as operations are injected.');
1054
+ console.log('Press CTRL-C to stop and exit.');
1055
+ console.log();
1056
+
1057
+ while (true) {
1058
+ console.log('Waiting for operations to be injected...');
1059
+ while (true) {
1060
+ const { stdout } = await runCommand(
1061
+ `docker exec ${containerName} octez-client rpc get /chains/main/mempool/pending_operations`,
1062
+ );
1063
+ const ops = JSON.parse(stdout);
1064
+ if (Array.isArray(ops.applied) && ops.applied.length > 0) break;
1065
+ await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for 1 second before checking again
1066
+ }
1067
+
1068
+ await runCommand(cmd);
1069
+ console.log('Block baked.');
1070
+ }
1071
+ } else {
1072
+ await runCommand(cmd);
1073
+ await sendAsyncRes('Block baked successfully.');
1074
+ }
1075
+ } else {
1076
+ try {
1077
+ await sendAsyncErr('Sandbox is not running. Please start the sandbox before attempting to bake a block.');
1078
+ } catch {
1079
+ // Nothing to see here.
1080
+ }
1081
+ }
1082
+ } catch (error) {
1083
+ await sendAsyncErr(getErrorMessage(`Failed to bake block`, error));
1084
+ }
1085
+ }
1086
+
1087
+ /**
1088
+ * Main proxy function to handle tasks.
1089
+ */
1090
+ export const proxy = async (taskArgs: Opts): Promise<void> => {
1091
+ if (!isTezBoxEnvironment(taskArgs)) {
1092
+ await sendAsyncErr(
1093
+ `This configuration doesn't appear to be configured to use TezBox environments. Check the ${taskArgs.env} environment in your .taq/config.json.`,
1094
+ );
1095
+ return;
1096
+ }
1097
+
1098
+ const taskName = taskArgs.task?.toLowerCase().trim();
1099
+
1100
+ const taskHandlers: Record<string, (args: Opts) => Promise<void>> = {
1101
+ 'start sandbox': startSandbox,
1102
+ 'stop sandbox': stopSandbox,
1103
+ 'restart sandbox': restartSandbox,
1104
+ 'list protocols': listProtocols,
1105
+ 'list-protocols': listProtocols,
1106
+ 'show protocols': listProtocols,
1107
+ 'show-protocols': listProtocols,
1108
+ 'list accounts': listAccounts,
1109
+ 'list-accounts': listAccounts,
1110
+ 'show accounts': listAccounts,
1111
+ 'show-accounts': listAccounts,
1112
+ 'bake': bakeBlock,
1113
+ 'bake block': bakeBlock,
1114
+ };
1115
+
1116
+ const handler = taskName ? taskHandlers[taskName] : undefined;
1117
+
1118
+ if (handler) {
1119
+ try {
1120
+ await handler(taskArgs);
1121
+ } catch (error) {
1122
+ await sendAsyncErr(getErrorMessage(`Error executing task '${taskArgs.task}'`, error));
1123
+ }
1124
+ } else {
1125
+ await sendAsyncErr(taskArgs.task ? `Unknown task: ${taskArgs.task}` : 'No task provided');
1126
+ }
1127
+ };
1128
+
1129
+ export default proxy;