ethnotary 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.
@@ -0,0 +1,369 @@
1
+ const { Command } = require('commander');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const inquirer = require('inquirer');
6
+ const chalk = require('chalk');
7
+ const { createOutput } = require('../../utils/output');
8
+
9
+ const ETHNOTARY_DIR = path.join(os.homedir(), '.ethnotary');
10
+ const CONFIG_PATH = path.join(ETHNOTARY_DIR, 'config.json');
11
+
12
+ // RPC provider suggestions
13
+ const RPC_PROVIDERS = {
14
+ infura: {
15
+ name: 'Infura',
16
+ url: 'https://infura.io',
17
+ description: 'Popular provider, requires API key'
18
+ },
19
+ alchemy: {
20
+ name: 'Alchemy',
21
+ url: 'https://alchemy.com',
22
+ description: 'Full-featured provider, requires API key'
23
+ },
24
+ quicknode: {
25
+ name: 'QuickNode',
26
+ url: 'https://quicknode.com',
27
+ description: 'High-performance provider, requires API key'
28
+ },
29
+ publicnode: {
30
+ name: 'PublicNode',
31
+ url: 'https://publicnode.com',
32
+ description: 'Free public RPCs (rate limited)'
33
+ }
34
+ };
35
+
36
+ // Default network definitions
37
+ const DEFAULT_NETWORKS = {
38
+ sepolia: { name: 'Sepolia', chainId: 11155111, testnet: true },
39
+ 'base-sepolia': { name: 'Base Sepolia', chainId: 84532, testnet: true },
40
+ 'arbitrum-sepolia': { name: 'Arbitrum Sepolia', chainId: 421614, testnet: true },
41
+ ethereum: { name: 'Ethereum Mainnet', chainId: 1, testnet: false },
42
+ base: { name: 'Base', chainId: 8453, testnet: false },
43
+ arbitrum: { name: 'Arbitrum One', chainId: 42161, testnet: false },
44
+ optimism: { name: 'Optimism', chainId: 10, testnet: false },
45
+ polygon: { name: 'Polygon', chainId: 137, testnet: false }
46
+ };
47
+
48
+ // Ensure directory exists
49
+ function ensureDir() {
50
+ if (!fs.existsSync(ETHNOTARY_DIR)) {
51
+ fs.mkdirSync(ETHNOTARY_DIR, { recursive: true });
52
+ }
53
+ }
54
+
55
+ // Load config
56
+ function loadConfig() {
57
+ if (!fs.existsSync(CONFIG_PATH)) {
58
+ return { networks: {}, rpc: {} };
59
+ }
60
+ try {
61
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
62
+ if (!cfg.networks) cfg.networks = {};
63
+ if (!cfg.rpc) cfg.rpc = {};
64
+ return cfg;
65
+ } catch {
66
+ return { networks: {}, rpc: {} };
67
+ }
68
+ }
69
+
70
+ // Save config
71
+ function saveConfig(config) {
72
+ ensureDir();
73
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
74
+ }
75
+
76
+ const config = new Command('config')
77
+ .description('Configuration commands');
78
+
79
+ // config rpc - Add or update RPC URL for a network
80
+ config
81
+ .command('rpc')
82
+ .description('Add or update RPC URL for a network')
83
+ .argument('[network]', 'Network name (e.g., sepolia, base, arbitrum)')
84
+ .option('--url <url>', 'RPC URL to set')
85
+ .action(async (network, options, command) => {
86
+ const globalOpts = command.parent.parent.opts();
87
+ const out = createOutput(globalOpts);
88
+
89
+ try {
90
+ const cfg = loadConfig();
91
+
92
+ // If no network specified, show current RPC config
93
+ if (!network) {
94
+ const rpcEntries = Object.entries(cfg.rpc);
95
+ if (rpcEntries.length === 0) {
96
+ out.info('No RPC URLs configured.');
97
+ out.info('Use "ethnotary config rpc <network> --url <url>" to add one.');
98
+ }
99
+ out.print({
100
+ rpc: cfg.rpc
101
+ });
102
+ return;
103
+ }
104
+
105
+ // If URL provided via flag, set it directly
106
+ if (options.url) {
107
+ if (!options.url.startsWith('http://') && !options.url.startsWith('https://')) {
108
+ out.error('RPC URL must start with http:// or https://');
109
+ return;
110
+ }
111
+ cfg.rpc[network] = options.url;
112
+ saveConfig(cfg);
113
+ out.success(`RPC URL set for ${network}`);
114
+ out.print({ network, url: options.url });
115
+ return;
116
+ }
117
+
118
+ // Interactive mode - prompt for URL
119
+ if (globalOpts.json) {
120
+ out.error('--url required in JSON mode');
121
+ return;
122
+ }
123
+
124
+ console.log(chalk.cyan(`\nConfiguring RPC for: ${network}`));
125
+ console.log(chalk.gray('\nWhere to get RPC URLs:'));
126
+ for (const [key, provider] of Object.entries(RPC_PROVIDERS)) {
127
+ console.log(chalk.gray(` • ${provider.name}: ${provider.url} - ${provider.description}`));
128
+ }
129
+ console.log('');
130
+
131
+ const { rpcUrl } = await inquirer.prompt([{
132
+ type: 'input',
133
+ name: 'rpcUrl',
134
+ message: `Enter RPC URL for ${network}:`,
135
+ validate: (input) => {
136
+ if (!input.startsWith('http://') && !input.startsWith('https://')) {
137
+ return 'RPC URL must start with http:// or https://';
138
+ }
139
+ return true;
140
+ }
141
+ }]);
142
+
143
+ cfg.rpc[network] = rpcUrl;
144
+ saveConfig(cfg);
145
+ out.success(`RPC URL set for ${network}`);
146
+ out.print({ network, url: rpcUrl });
147
+
148
+ } catch (error) {
149
+ out.error(error.message);
150
+ }
151
+ });
152
+
153
+ // config network - Add or update a network
154
+ config
155
+ .command('network')
156
+ .description('Add or update a network configuration')
157
+ .argument('[network]', 'Network name/key')
158
+ .option('--name <name>', 'Display name for the network')
159
+ .option('--chain-id <chainId>', 'Chain ID', parseInt)
160
+ .option('--rpc <url>', 'RPC URL for the network')
161
+ .option('--testnet', 'Mark as testnet')
162
+ .action(async (network, options, command) => {
163
+ const globalOpts = command.parent.parent.opts();
164
+ const out = createOutput(globalOpts);
165
+
166
+ try {
167
+ const cfg = loadConfig();
168
+
169
+ // If no network specified, list all networks
170
+ if (!network) {
171
+ const allNetworks = { ...DEFAULT_NETWORKS };
172
+ // Merge custom networks
173
+ for (const [key, value] of Object.entries(cfg.networks)) {
174
+ allNetworks[key] = value;
175
+ }
176
+ // Add RPC status
177
+ const networksWithStatus = Object.entries(allNetworks).map(([key, net]) => ({
178
+ key,
179
+ ...net,
180
+ rpcConfigured: !!(cfg.rpc[key] || process.env[`${key.toUpperCase().replace('-', '_')}_RPC_URL`])
181
+ }));
182
+
183
+ if (!globalOpts.json) {
184
+ console.log(chalk.bold('\nConfigured Networks:\n'));
185
+ for (const net of networksWithStatus) {
186
+ const status = net.rpcConfigured ? chalk.green('✓') : chalk.yellow('○');
187
+ const type = net.testnet ? chalk.gray('(testnet)') : '';
188
+ console.log(` ${status} ${net.key} - ${net.name} ${type}`);
189
+ console.log(chalk.gray(` Chain ID: ${net.chainId}`));
190
+ }
191
+ console.log('');
192
+ console.log(chalk.gray('Use "ethnotary config network <name>" to add/edit a network'));
193
+ console.log(chalk.gray('Use "ethnotary config rpc <network>" to configure RPC URL\n'));
194
+ }
195
+
196
+ out.print({ networks: networksWithStatus });
197
+ return;
198
+ }
199
+
200
+ // Check if it's a known network
201
+ const existing = cfg.networks[network] || DEFAULT_NETWORKS[network];
202
+
203
+ // If options provided, update directly
204
+ if (options.name || options.chainId || options.rpc) {
205
+ const netConfig = existing || {};
206
+ if (options.name) netConfig.name = options.name;
207
+ if (options.chainId) netConfig.chainId = options.chainId;
208
+ if (options.testnet !== undefined) netConfig.testnet = options.testnet;
209
+
210
+ cfg.networks[network] = netConfig;
211
+
212
+ if (options.rpc) {
213
+ cfg.rpc[network] = options.rpc;
214
+ }
215
+
216
+ saveConfig(cfg);
217
+ out.success(`Network "${network}" configured`);
218
+ out.print({ network, config: netConfig, rpc: cfg.rpc[network] || null });
219
+ return;
220
+ }
221
+
222
+ // Interactive mode
223
+ if (globalOpts.json) {
224
+ out.error('Options required in JSON mode (--name, --chain-id, --rpc)');
225
+ return;
226
+ }
227
+
228
+ console.log(chalk.cyan(`\nConfiguring network: ${network}`));
229
+ if (existing) {
230
+ console.log(chalk.gray(`Existing config: ${existing.name}, Chain ID: ${existing.chainId}`));
231
+ }
232
+
233
+ const answers = await inquirer.prompt([
234
+ {
235
+ type: 'input',
236
+ name: 'name',
237
+ message: 'Display name:',
238
+ default: existing?.name || network
239
+ },
240
+ {
241
+ type: 'number',
242
+ name: 'chainId',
243
+ message: 'Chain ID:',
244
+ default: existing?.chainId,
245
+ validate: (input) => input > 0 ? true : 'Chain ID must be positive'
246
+ },
247
+ {
248
+ type: 'confirm',
249
+ name: 'testnet',
250
+ message: 'Is this a testnet?',
251
+ default: existing?.testnet || false
252
+ },
253
+ {
254
+ type: 'confirm',
255
+ name: 'configureRpc',
256
+ message: 'Configure RPC URL now?',
257
+ default: !cfg.rpc[network]
258
+ }
259
+ ]);
260
+
261
+ cfg.networks[network] = {
262
+ name: answers.name,
263
+ chainId: answers.chainId,
264
+ testnet: answers.testnet
265
+ };
266
+
267
+ if (answers.configureRpc) {
268
+ console.log(chalk.gray('\nWhere to get RPC URLs:'));
269
+ for (const [key, provider] of Object.entries(RPC_PROVIDERS)) {
270
+ console.log(chalk.gray(` • ${provider.name}: ${provider.url}`));
271
+ }
272
+ console.log('');
273
+
274
+ const { rpcUrl } = await inquirer.prompt([{
275
+ type: 'input',
276
+ name: 'rpcUrl',
277
+ message: 'RPC URL:',
278
+ validate: (input) => {
279
+ if (!input) return true; // Allow empty
280
+ if (!input.startsWith('http://') && !input.startsWith('https://')) {
281
+ return 'RPC URL must start with http:// or https://';
282
+ }
283
+ return true;
284
+ }
285
+ }]);
286
+
287
+ if (rpcUrl) {
288
+ cfg.rpc[network] = rpcUrl;
289
+ }
290
+ }
291
+
292
+ saveConfig(cfg);
293
+ out.success(`Network "${network}" configured`);
294
+ out.print({
295
+ network,
296
+ config: cfg.networks[network],
297
+ rpc: cfg.rpc[network] || null
298
+ });
299
+
300
+ } catch (error) {
301
+ out.error(error.message);
302
+ }
303
+ });
304
+
305
+ // config show - Show all config
306
+ config
307
+ .command('show')
308
+ .description('Show all configuration')
309
+ .action(async (options, command) => {
310
+ const globalOpts = command.parent.parent.opts();
311
+ const out = createOutput(globalOpts);
312
+
313
+ try {
314
+ const cfg = loadConfig();
315
+
316
+ if (!globalOpts.json) {
317
+ console.log(chalk.bold('\nEthnotary Configuration\n'));
318
+ console.log(chalk.gray(`Config file: ${CONFIG_PATH}`));
319
+
320
+ console.log(chalk.bold('\nRPC URLs:'));
321
+ const rpcEntries = Object.entries(cfg.rpc);
322
+ if (rpcEntries.length === 0) {
323
+ console.log(chalk.gray(' No RPC URLs configured'));
324
+ } else {
325
+ for (const [network, url] of rpcEntries) {
326
+ console.log(` ${network}: ${chalk.gray(url.substring(0, 50))}...`);
327
+ }
328
+ }
329
+
330
+ console.log(chalk.bold('\nCustom Networks:'));
331
+ const netEntries = Object.entries(cfg.networks);
332
+ if (netEntries.length === 0) {
333
+ console.log(chalk.gray(' Using default networks only'));
334
+ } else {
335
+ for (const [key, net] of netEntries) {
336
+ console.log(` ${key}: ${net.name} (Chain ID: ${net.chainId})`);
337
+ }
338
+ }
339
+ console.log('');
340
+ }
341
+
342
+ out.print({
343
+ configPath: CONFIG_PATH,
344
+ rpc: cfg.rpc,
345
+ networks: cfg.networks
346
+ });
347
+
348
+ } catch (error) {
349
+ out.error(error.message);
350
+ }
351
+ });
352
+
353
+ // config path - Show config file path
354
+ config
355
+ .command('path')
356
+ .description('Show configuration file paths')
357
+ .action(async (options, command) => {
358
+ const globalOpts = command.parent.parent.opts();
359
+ const out = createOutput(globalOpts);
360
+
361
+ out.print({
362
+ configDir: ETHNOTARY_DIR,
363
+ configFile: CONFIG_PATH,
364
+ keystoreFile: path.join(ETHNOTARY_DIR, 'keystore.json'),
365
+ contractsFile: path.join(ETHNOTARY_DIR, 'contracts.json')
366
+ });
367
+ });
368
+
369
+ module.exports = config;
@@ -0,0 +1,139 @@
1
+ const { Command } = require('commander');
2
+ const { ethers } = require('ethers');
3
+ const { createOutput } = require('../../utils/output');
4
+ const {
5
+ addContact,
6
+ listContacts,
7
+ removeContact,
8
+ getContact
9
+ } = require('../../utils/contacts');
10
+
11
+ const contact = new Command('contact')
12
+ .description('Owner contact management for notifications');
13
+
14
+ // contact add - Add or update owner contact info
15
+ contact
16
+ .command('add')
17
+ .description('Add or update contact info for an owner address')
18
+ .requiredOption('--address <address>', 'Owner Ethereum address')
19
+ .option('--telegram <chatId>', 'Telegram chat ID (numeric)')
20
+ .option('--whatsapp <phone>', 'WhatsApp phone number (e.g., +15551234567)')
21
+ .option('--email <email>', 'Email address for notifications')
22
+ .option('--webhook <url>', 'Webhook URL for notifications')
23
+ .action(async (options, command) => {
24
+ const globalOpts = command.parent.parent.opts();
25
+ const out = createOutput(globalOpts);
26
+
27
+ try {
28
+ if (!ethers.isAddress(options.address)) {
29
+ out.error(`Invalid address: ${options.address}`);
30
+ return;
31
+ }
32
+
33
+ if (!options.telegram && !options.whatsapp && !options.email && !options.webhook) {
34
+ out.error('At least one contact method required: --telegram, --whatsapp, --email, or --webhook');
35
+ return;
36
+ }
37
+
38
+ const contactInfo = {};
39
+ if (options.telegram) contactInfo.telegram = options.telegram;
40
+ if (options.whatsapp) contactInfo.whatsapp = options.whatsapp;
41
+ if (options.email) contactInfo.email = options.email;
42
+ if (options.webhook) contactInfo.webhook = options.webhook;
43
+
44
+ const saved = addContact(options.address, contactInfo);
45
+
46
+ out.success(`Contact saved for ${options.address}`);
47
+ out.print({
48
+ address: ethers.getAddress(options.address),
49
+ ...saved
50
+ });
51
+
52
+ } catch (error) {
53
+ out.error(error.message);
54
+ }
55
+ });
56
+
57
+ // contact list - List all contacts
58
+ contact
59
+ .command('list')
60
+ .description('List all saved owner contacts')
61
+ .action(async (options, command) => {
62
+ const globalOpts = command.parent.parent.opts();
63
+ const out = createOutput(globalOpts);
64
+
65
+ try {
66
+ const contacts = listContacts();
67
+
68
+ if (contacts.length === 0) {
69
+ out.info('No contacts saved. Use "ethnotary contact add" to add one.');
70
+ out.print({ contacts: [] });
71
+ return;
72
+ }
73
+
74
+ out.print({
75
+ count: contacts.length,
76
+ contacts
77
+ });
78
+
79
+ } catch (error) {
80
+ out.error(error.message);
81
+ }
82
+ });
83
+
84
+ // contact show - Show contact for specific address
85
+ contact
86
+ .command('show')
87
+ .description('Show contact info for a specific address')
88
+ .requiredOption('--address <address>', 'Owner Ethereum address')
89
+ .action(async (options, command) => {
90
+ const globalOpts = command.parent.parent.opts();
91
+ const out = createOutput(globalOpts);
92
+
93
+ try {
94
+ if (!ethers.isAddress(options.address)) {
95
+ out.error(`Invalid address: ${options.address}`);
96
+ return;
97
+ }
98
+
99
+ const contactInfo = getContact(options.address);
100
+
101
+ if (!contactInfo) {
102
+ out.info(`No contact found for ${options.address}`);
103
+ out.print({ address: options.address, contact: null });
104
+ return;
105
+ }
106
+
107
+ out.print({
108
+ address: ethers.getAddress(options.address),
109
+ ...contactInfo
110
+ });
111
+
112
+ } catch (error) {
113
+ out.error(error.message);
114
+ }
115
+ });
116
+
117
+ // contact remove - Remove a contact
118
+ contact
119
+ .command('remove')
120
+ .description('Remove contact info for an owner address')
121
+ .requiredOption('--address <address>', 'Owner Ethereum address')
122
+ .action(async (options, command) => {
123
+ const globalOpts = command.parent.parent.opts();
124
+ const out = createOutput(globalOpts);
125
+
126
+ try {
127
+ removeContact(options.address);
128
+ out.success(`Contact removed for ${options.address}`);
129
+ out.print({
130
+ address: options.address,
131
+ status: 'removed'
132
+ });
133
+
134
+ } catch (error) {
135
+ out.error(error.message);
136
+ }
137
+ });
138
+
139
+ module.exports = contact;
@@ -0,0 +1,197 @@
1
+ const { Command } = require('commander');
2
+ const { ethers } = require('ethers');
3
+ const { createOutput } = require('../../utils/output');
4
+ const {
5
+ saveContract,
6
+ listContracts,
7
+ removeContract,
8
+ setDefaultContract,
9
+ getDefaultContract
10
+ } = require('../../utils/contracts');
11
+
12
+ const contract = new Command('contract')
13
+ .description('Contract storage commands');
14
+
15
+ // contract add - Save a contract
16
+ contract
17
+ .command('add')
18
+ .description('Save a contract with an alias')
19
+ .requiredOption('--alias <alias>', 'Alias for the contract')
20
+ .requiredOption('--address <address>', 'Contract address')
21
+ .option('--label <label>', 'Optional label/description')
22
+ .option('--network <network>', 'Network the contract is on')
23
+ .action(async (options, command) => {
24
+ const globalOpts = command.parent.parent.opts();
25
+ const out = createOutput(globalOpts);
26
+
27
+ try {
28
+ if (!ethers.isAddress(options.address)) {
29
+ out.error(`Invalid address: ${options.address}`);
30
+ return;
31
+ }
32
+
33
+ const network = options.network || globalOpts.network;
34
+ const saved = saveContract(options.alias, options.address, network, options.label);
35
+
36
+ out.success(`Contract saved as "${options.alias}"`);
37
+ out.print({
38
+ alias: options.alias,
39
+ address: options.address,
40
+ network,
41
+ label: options.label || '',
42
+ created: saved.created
43
+ });
44
+
45
+ } catch (error) {
46
+ out.error(error.message);
47
+ }
48
+ });
49
+
50
+ // contract list - List saved contracts
51
+ contract
52
+ .command('list')
53
+ .description('List all saved contracts')
54
+ .action(async (options, command) => {
55
+ const globalOpts = command.parent.parent.opts();
56
+ const out = createOutput(globalOpts);
57
+
58
+ try {
59
+ const contracts = listContracts();
60
+
61
+ if (contracts.length === 0) {
62
+ out.info('No contracts saved. Use "ethnotary contract add" to save one.');
63
+ out.print({ contracts: [] });
64
+ return;
65
+ }
66
+
67
+ out.print({
68
+ count: contracts.length,
69
+ contracts
70
+ });
71
+
72
+ } catch (error) {
73
+ out.error(error.message);
74
+ }
75
+ });
76
+
77
+ // contract remove - Remove a saved contract
78
+ contract
79
+ .command('remove')
80
+ .description('Remove a saved contract')
81
+ .requiredOption('--alias <alias>', 'Alias of contract to remove')
82
+ .action(async (options, command) => {
83
+ const globalOpts = command.parent.parent.opts();
84
+ const out = createOutput(globalOpts);
85
+
86
+ try {
87
+ removeContract(options.alias);
88
+ out.success(`Contract "${options.alias}" removed`);
89
+ out.print({
90
+ alias: options.alias,
91
+ status: 'removed'
92
+ });
93
+
94
+ } catch (error) {
95
+ out.error(error.message);
96
+ }
97
+ });
98
+
99
+ // contract default - Set or show default contract (legacy, kept for compatibility)
100
+ contract
101
+ .command('default')
102
+ .description('Set or show the default contract (alias for checkout/current)')
103
+ .option('--alias <alias>', 'Set this alias as default')
104
+ .action(async (options, command) => {
105
+ const globalOpts = command.parent.parent.opts();
106
+ const out = createOutput(globalOpts);
107
+
108
+ try {
109
+ if (options.alias) {
110
+ setDefaultContract(options.alias);
111
+ out.success(`Switched to "${options.alias}"`);
112
+ out.print({
113
+ current: options.alias,
114
+ status: 'checked_out'
115
+ });
116
+ } else {
117
+ const defaultContract = getDefaultContract();
118
+ if (defaultContract) {
119
+ out.print({
120
+ current: defaultContract.alias,
121
+ address: defaultContract.address,
122
+ network: defaultContract.network
123
+ });
124
+ } else {
125
+ out.info('No contract checked out. Use "ethnotary checkout <alias>"');
126
+ out.print({ current: null });
127
+ }
128
+ }
129
+
130
+ } catch (error) {
131
+ out.error(error.message);
132
+ }
133
+ });
134
+
135
+ // contract checkout - Switch to a different contract (like git checkout)
136
+ contract
137
+ .command('checkout <alias>')
138
+ .description('Switch to a different contract (like git checkout)')
139
+ .action(async (alias, command) => {
140
+ const globalOpts = command.parent.parent.opts();
141
+ const out = createOutput(globalOpts);
142
+
143
+ try {
144
+ setDefaultContract(alias);
145
+ const contract = getDefaultContract();
146
+
147
+ out.success(`Switched to "${alias}"`);
148
+ out.print({
149
+ current: alias,
150
+ address: contract.address,
151
+ network: contract.network,
152
+ label: contract.label || ''
153
+ });
154
+
155
+ } catch (error) {
156
+ out.error(error.message);
157
+ }
158
+ });
159
+
160
+ // contract current - Show current checked out contract (like git branch)
161
+ contract
162
+ .command('current')
163
+ .alias('status')
164
+ .description('Show the currently active contract')
165
+ .action(async (options, command) => {
166
+ const globalOpts = command.parent.parent.opts();
167
+ const out = createOutput(globalOpts);
168
+
169
+ try {
170
+ const current = getDefaultContract();
171
+
172
+ if (current) {
173
+ if (!globalOpts.json) {
174
+ out.info(`On account: ${current.alias}`);
175
+ out.info(`Address: ${current.address}`);
176
+ out.info(`Network: ${current.network}`);
177
+ if (current.label) out.info(`Label: ${current.label}`);
178
+ }
179
+ out.print({
180
+ current: current.alias,
181
+ address: current.address,
182
+ network: current.network,
183
+ label: current.label || ''
184
+ });
185
+ } else {
186
+ out.warn('No contract checked out.');
187
+ out.info('Use "ethnotary checkout <alias>" to switch to a contract.');
188
+ out.info('Use "ethnotary contract list" to see available contracts.');
189
+ out.print({ current: null });
190
+ }
191
+
192
+ } catch (error) {
193
+ out.error(error.message);
194
+ }
195
+ });
196
+
197
+ module.exports = contract;