ethnotary 1.0.0 → 1.0.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.
- package/README.md +91 -27
- package/cli/commands/account/index.js +55 -20
- package/cli/commands/wallet/index.js +9 -8
- package/cli/index.js +20 -4
- package/cli/utils/auth.js +18 -15
- package/cli/utils/constants.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,15 +1,66 @@
|
|
|
1
1
|
# Ethnotary CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A command-line tool for managing multi-signature wallets across EVM networks. Built for teams, DAOs, and AI agents who need secure, collaborative control over on-chain treasuries.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**Key Use Cases:**
|
|
6
|
+
- **Treasury Management** – Collaboratively manage funds with multi-sig approval workflows
|
|
7
|
+
- **Payment Collection** – Receive and track payments across multiple networks
|
|
8
|
+
- **AI Agent Integration** – Enable AI agents to submit transactions while humans retain approval authority
|
|
9
|
+
- **Cross-Chain Operations** – Deploy and manage the same wallet on Sepolia, and Base-Sepolia, and more networks to come.
|
|
10
|
+
|
|
11
|
+
With multi-signature approvals, every transaction requires consensus—keeping you in control while enabling automation and collaboration.
|
|
12
|
+
|
|
13
|
+
## How Multi-Signature Works
|
|
14
|
+
|
|
15
|
+
A multi-signature (multi-sig) wallet requires multiple approvals before a transaction can execute. You define the number of **owners** and how many **confirmations** are required.
|
|
16
|
+
|
|
17
|
+
**Common Configurations:**
|
|
18
|
+
|
|
19
|
+
| Setup | Use Case |
|
|
20
|
+
|-------|----------|
|
|
21
|
+
| **1 of 1** | Single owner, instant execution. Good for personal wallets or AI agents with full autonomy. |
|
|
22
|
+
| **1 of 2** | Either owner can approve. Useful for shared access (e.g., you + your AI agent). |
|
|
23
|
+
| **2 of 2** | Both owners must approve. Maximum security for two-party agreements. |
|
|
24
|
+
| **2 of 3** | Any 2 of 3 owners must approve. Balances security with availability—if one key is lost, funds are still accessible. |
|
|
25
|
+
| **3 of 5** | Majority approval for DAOs or teams. Prevents single points of failure. |
|
|
6
26
|
|
|
27
|
+
**Example: AI Agent Integration (1 of 2)**
|
|
28
|
+
```
|
|
29
|
+
Owners: [Your Wallet, AI Agent Wallet]
|
|
30
|
+
Required: 1
|
|
31
|
+
|
|
32
|
+
→ AI agent can submit AND execute transactions autonomously
|
|
33
|
+
→ You can also execute transactions when needed
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Example: Human Oversight (2 of 2)**
|
|
37
|
+
```
|
|
38
|
+
Owners: [Your Wallet, AI Agent Wallet]
|
|
39
|
+
Required: 2
|
|
40
|
+
|
|
41
|
+
→ AI agent submits transactions
|
|
42
|
+
→ You review and approve before execution
|
|
43
|
+
→ Nothing happens without your confirmation
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Prerequisites
|
|
47
|
+
|
|
48
|
+
- **Node.js** (v16 or higher) - [Download](https://nodejs.org/)
|
|
49
|
+
- **npm** (comes with Node.js)
|
|
50
|
+
- **RPC URL** – Connection to your target network (e.g., from [Infura](https://infura.io), [Alchemy](https://alchemy.com), or a public RPC)
|
|
51
|
+
- **ETH for gas** – Enough cryptocurrency on your target network to pay for transaction fees
|
|
52
|
+
|
|
53
|
+
Verify Node.js installation:
|
|
7
54
|
```bash
|
|
8
|
-
#
|
|
9
|
-
npm
|
|
55
|
+
node --version # Should show v16.x.x or higher
|
|
56
|
+
npm --version # Should show 8.x.x or higher
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
10
60
|
|
|
11
|
-
|
|
12
|
-
npm
|
|
61
|
+
```bash
|
|
62
|
+
# Install globally from npm
|
|
63
|
+
npm install -g ethnotary
|
|
13
64
|
```
|
|
14
65
|
|
|
15
66
|
## Quick Start
|
|
@@ -60,6 +111,31 @@ ethnotary wallet import # Import existing key/mnemonic
|
|
|
60
111
|
ethnotary wallet show # Display wallet address
|
|
61
112
|
```
|
|
62
113
|
|
|
114
|
+
## Wallet Security
|
|
115
|
+
|
|
116
|
+
Your private keys **never leave your machine**. Ethnotary stores wallet credentials locally in an encrypted keystore file, protected by a password you choose.
|
|
117
|
+
|
|
118
|
+
**Wallet Priority:**
|
|
119
|
+
1. `--private-key` flag – Explicit override for single commands
|
|
120
|
+
2. **Encrypted keystore** – Default, password-protected (recommended)
|
|
121
|
+
3. `PRIVATE_KEY` env var – Fallback for scripts/automation
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
# Create a new encrypted wallet (stored locally)
|
|
125
|
+
ethnotary wallet init
|
|
126
|
+
|
|
127
|
+
# View your wallet address
|
|
128
|
+
ethnotary wallet show
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**What stays on your machine:**
|
|
132
|
+
- Private keys (encrypted in keystore)
|
|
133
|
+
- Wallet passwords (never stored, prompted each time)
|
|
134
|
+
- RPC URLs and API keys (in `~/.ethnotary/config.json`)
|
|
135
|
+
|
|
136
|
+
**What goes on-chain:**
|
|
137
|
+
- Only signed transactions you explicitly submit
|
|
138
|
+
- Your public address (visible in transactions)
|
|
63
139
|
### Account Management
|
|
64
140
|
|
|
65
141
|
Account management commands apply changes across ALL networks the contract is deployed on.
|
|
@@ -333,33 +409,21 @@ All configuration is stored in `~/.ethnotary/`:
|
|
|
333
409
|
|
|
334
410
|
Supported networks (use `--network <name>` or `--chain-id <id>`):
|
|
335
411
|
|
|
336
|
-
**
|
|
412
|
+
**Currently Supported:**
|
|
413
|
+
| Network | Name | Chain ID |
|
|
414
|
+
|---------|------|----------|
|
|
415
|
+
| Sepolia | `sepolia` | 11155111 |
|
|
416
|
+
| Base Sepolia | `base-sepolia` | 84532 |
|
|
417
|
+
|
|
418
|
+
**Coming Soon:**
|
|
337
419
|
| Network | Name | Chain ID |
|
|
338
420
|
|---------|------|----------|
|
|
339
421
|
| Ethereum Mainnet | `ethereum` | 1 |
|
|
340
|
-
| Optimism | `optimism` | 10 |
|
|
341
422
|
| Base | `base` | 8453 |
|
|
342
423
|
| Arbitrum One | `arbitrum` | 42161 |
|
|
343
|
-
|
|
|
344
|
-
| zkSync Era | `zksync-era` | 324 |
|
|
345
|
-
| Scroll | `scroll` | 534352 |
|
|
346
|
-
| Polygon zkEVM | `polygon-zkevm` | 1101 |
|
|
347
|
-
| Linea | `linea` | 59144 |
|
|
424
|
+
| Optimism | `optimism` | 10 |
|
|
348
425
|
| Polygon PoS | `polygon` | 137 |
|
|
349
|
-
|
|
|
350
|
-
| Avalanche C-Chain | `avalanche` | 43114 |
|
|
351
|
-
| Celo | `celo` | 42220 |
|
|
352
|
-
| Soneium | `soneium` | 1868 |
|
|
353
|
-
|
|
354
|
-
**Testnets:**
|
|
355
|
-
| Network | Name | Chain ID |
|
|
356
|
-
|---------|------|----------|
|
|
357
|
-
| Sepolia | `sepolia` | 11155111 |
|
|
358
|
-
| Base Sepolia | `base-sepolia` | 84532 |
|
|
359
|
-
| Arbitrum Sepolia | `arbitrum-sepolia` | 421614 |
|
|
360
|
-
| Polygon Mumbai | `polygon-mumbai` | 80001 |
|
|
361
|
-
| Polygon Amoy | `polygon-amoy` | 80002 |
|
|
362
|
-
| Avalanche Fuji | `avalanche-fuji` | 43113 |
|
|
426
|
+
| *...and more* | | |
|
|
363
427
|
|
|
364
428
|
**Configure RPC URLs:**
|
|
365
429
|
```bash
|
|
@@ -168,40 +168,75 @@ account
|
|
|
168
168
|
|
|
169
169
|
try {
|
|
170
170
|
const address = resolveAddress(options.address);
|
|
171
|
-
// Get first network from contract's networks, or use specified network
|
|
172
171
|
const contractNetworks = getContractNetworks(options.address);
|
|
173
|
-
const
|
|
174
|
-
const network = await getNetwork(networkKey);
|
|
175
|
-
const provider = new ethers.JsonRpcProvider(network.rpc);
|
|
172
|
+
const networksToQuery = contractNetworks.length > 0 ? contractNetworks : ['sepolia'];
|
|
176
173
|
|
|
177
|
-
|
|
178
|
-
const code = await provider.getCode(address);
|
|
179
|
-
if (code === '0x') {
|
|
180
|
-
out.error(`No contract found at ${address} on ${network.name}`);
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
174
|
+
out.startSpinner('Fetching account info across all networks...');
|
|
183
175
|
|
|
184
|
-
|
|
176
|
+
// Fetch balances from all networks in parallel
|
|
177
|
+
const balancePromises = networksToQuery.map(async (networkKey) => {
|
|
178
|
+
try {
|
|
179
|
+
const network = await getNetwork(networkKey);
|
|
180
|
+
const provider = new ethers.JsonRpcProvider(network.rpc);
|
|
181
|
+
const balance = await provider.getBalance(address);
|
|
182
|
+
return { network: networkKey, balance: ethers.formatEther(balance), raw: balance };
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return { network: networkKey, balance: 'unavailable', error: e.message };
|
|
185
|
+
}
|
|
186
|
+
});
|
|
185
187
|
|
|
186
|
-
|
|
188
|
+
// Get account details from first available network
|
|
189
|
+
let owners = [];
|
|
190
|
+
let required = 0;
|
|
191
|
+
let primaryNetwork = null;
|
|
187
192
|
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
+
for (const networkKey of networksToQuery) {
|
|
194
|
+
try {
|
|
195
|
+
const network = await getNetwork(networkKey);
|
|
196
|
+
const provider = new ethers.JsonRpcProvider(network.rpc);
|
|
197
|
+
const code = await provider.getCode(address);
|
|
198
|
+
if (code !== '0x') {
|
|
199
|
+
const multisig = new ethers.Contract(address, MULTISIG_ABI, provider);
|
|
200
|
+
[owners, required] = await Promise.all([
|
|
201
|
+
multisig.getOwners(),
|
|
202
|
+
multisig.required()
|
|
203
|
+
]);
|
|
204
|
+
primaryNetwork = networkKey;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
} catch (e) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!primaryNetwork) {
|
|
213
|
+
out.failSpinner('Contract not found on any network');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const balances = await Promise.all(balancePromises);
|
|
218
|
+
const totalBalance = balances.reduce((sum, b) => {
|
|
219
|
+
if (b.raw) return sum + b.raw;
|
|
220
|
+
return sum;
|
|
221
|
+
}, 0n);
|
|
193
222
|
|
|
194
223
|
out.succeedSpinner('Account info retrieved');
|
|
195
224
|
|
|
225
|
+
// Format balances object
|
|
226
|
+
const balancesByNetwork = {};
|
|
227
|
+
for (const b of balances) {
|
|
228
|
+
balancesByNetwork[b.network] = b.error ? b.balance : `${b.balance} ETH`;
|
|
229
|
+
}
|
|
230
|
+
|
|
196
231
|
out.print({
|
|
197
232
|
address,
|
|
198
|
-
|
|
199
|
-
networks: contractNetworks,
|
|
233
|
+
networks: networksToQuery,
|
|
200
234
|
owners: owners.map(o => o),
|
|
201
235
|
required: Number(required),
|
|
202
236
|
ownerCount: owners.length,
|
|
203
237
|
confirmationsNeeded: `${required} of ${owners.length}`,
|
|
204
|
-
|
|
238
|
+
balances: balancesByNetwork,
|
|
239
|
+
totalBalance: ethers.formatEther(totalBalance) + ' ETH'
|
|
205
240
|
});
|
|
206
241
|
|
|
207
242
|
} catch (error) {
|
|
@@ -151,26 +151,27 @@ wallet
|
|
|
151
151
|
const out = createOutput(globalOpts);
|
|
152
152
|
|
|
153
153
|
try {
|
|
154
|
-
//
|
|
154
|
+
// Priority 1: --private-key flag (explicit override)
|
|
155
155
|
if (globalOpts.privateKey) {
|
|
156
156
|
const wallet = new ethers.Wallet(globalOpts.privateKey);
|
|
157
157
|
out.print({ address: wallet.address, source: 'private-key-flag' });
|
|
158
158
|
return;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
if
|
|
162
|
-
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY);
|
|
163
|
-
out.print({ address: wallet.address, source: 'environment' });
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Check keystore
|
|
161
|
+
// Priority 2: Keystore (default if exists)
|
|
168
162
|
if (keystoreExists()) {
|
|
169
163
|
const address = getKeystoreAddress();
|
|
170
164
|
out.print({ address: address, source: 'keystore' });
|
|
171
165
|
return;
|
|
172
166
|
}
|
|
173
167
|
|
|
168
|
+
// Priority 3: Environment variable (fallback)
|
|
169
|
+
if (process.env.PRIVATE_KEY) {
|
|
170
|
+
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY);
|
|
171
|
+
out.print({ address: wallet.address, source: 'environment' });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
174
175
|
out.error('No wallet configured. Use --private-key, set PRIVATE_KEY env var, or run "ethnotary wallet init"');
|
|
175
176
|
|
|
176
177
|
} catch (error) {
|
package/cli/index.js
CHANGED
|
@@ -558,18 +558,34 @@ async function checkRpcConfig() {
|
|
|
558
558
|
const receipt = await tx.wait();
|
|
559
559
|
|
|
560
560
|
// Get deployed address from event or use predicted
|
|
561
|
-
let actualAddress =
|
|
561
|
+
let actualAddress = null;
|
|
562
562
|
for (const log of receipt.logs) {
|
|
563
563
|
try {
|
|
564
564
|
const parsed = factory.interface.parseLog(log);
|
|
565
565
|
if (parsed && parsed.name === 'NewMSACreated') {
|
|
566
|
-
|
|
566
|
+
// The event has a single indexed address parameter
|
|
567
|
+
actualAddress = parsed.args.msaAddress || parsed.args[0];
|
|
567
568
|
break;
|
|
568
569
|
}
|
|
569
|
-
} catch (e) {
|
|
570
|
+
} catch (e) {
|
|
571
|
+
// Log might be from a different contract, try parsing the topic directly
|
|
572
|
+
// NewMSACreated(address indexed msaAddress) - topic[1] contains the address
|
|
573
|
+
if (log.topics && log.topics.length >= 2) {
|
|
574
|
+
const eventSig = ethers.id('NewMSACreated(address)');
|
|
575
|
+
if (log.topics[0] === eventSig) {
|
|
576
|
+
actualAddress = ethers.getAddress('0x' + log.topics[1].slice(26));
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Fallback to predicted address if event parsing failed
|
|
584
|
+
if (!actualAddress) {
|
|
585
|
+
actualAddress = predictedAddress;
|
|
570
586
|
}
|
|
571
587
|
|
|
572
|
-
if (!deployedAddress) {
|
|
588
|
+
if (!deployedAddress && actualAddress) {
|
|
573
589
|
deployedAddress = actualAddress;
|
|
574
590
|
}
|
|
575
591
|
|
package/cli/utils/auth.js
CHANGED
|
@@ -15,12 +15,12 @@ function ensureDir() {
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Get wallet from various sources (priority order):
|
|
18
|
-
* 1. --private-key flag
|
|
19
|
-
* 2.
|
|
20
|
-
* 3.
|
|
18
|
+
* 1. --private-key flag (explicit override)
|
|
19
|
+
* 2. Encrypted keystore (default if exists)
|
|
20
|
+
* 3. PRIVATE_KEY env var (fallback for scripts/automation)
|
|
21
21
|
*/
|
|
22
22
|
async function getWallet(options = {}) {
|
|
23
|
-
// Priority 1: Direct private key from CLI flag
|
|
23
|
+
// Priority 1: Direct private key from CLI flag (explicit override)
|
|
24
24
|
if (options.privateKey) {
|
|
25
25
|
try {
|
|
26
26
|
return new ethers.Wallet(options.privateKey);
|
|
@@ -29,20 +29,14 @@ async function getWallet(options = {}) {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
// Priority 2:
|
|
33
|
-
if (process.env.PRIVATE_KEY) {
|
|
34
|
-
try {
|
|
35
|
-
return new ethers.Wallet(process.env.PRIVATE_KEY);
|
|
36
|
-
} catch (e) {
|
|
37
|
-
throw new Error(`Invalid PRIVATE_KEY in environment: ${e.message}`);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Priority 3: Encrypted keystore
|
|
32
|
+
// Priority 2: Encrypted keystore (default if exists)
|
|
42
33
|
if (keystoreExists()) {
|
|
43
34
|
if (!options.password) {
|
|
44
|
-
// In non-interactive mode (--json),
|
|
35
|
+
// In non-interactive mode (--json), fall back to env var
|
|
45
36
|
if (options.json) {
|
|
37
|
+
if (process.env.PRIVATE_KEY) {
|
|
38
|
+
return new ethers.Wallet(process.env.PRIVATE_KEY);
|
|
39
|
+
}
|
|
46
40
|
throw new Error('No private key provided. Use --private-key or set PRIVATE_KEY env var.');
|
|
47
41
|
}
|
|
48
42
|
// Interactive mode - prompt for password
|
|
@@ -58,6 +52,15 @@ async function getWallet(options = {}) {
|
|
|
58
52
|
return await loadKeystore(options.password);
|
|
59
53
|
}
|
|
60
54
|
|
|
55
|
+
// Priority 3: Environment variable (fallback for scripts/automation)
|
|
56
|
+
if (process.env.PRIVATE_KEY) {
|
|
57
|
+
try {
|
|
58
|
+
return new ethers.Wallet(process.env.PRIVATE_KEY);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
throw new Error(`Invalid PRIVATE_KEY in environment: ${e.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
61
64
|
throw new Error('No wallet configured. Use --private-key, set PRIVATE_KEY env var, or run "ethnotary wallet init"');
|
|
62
65
|
}
|
|
63
66
|
|
package/cli/utils/constants.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
// Default MSA Factory address - same across all EVM networks via CREATE2
|
|
8
|
-
const DEFAULT_MSA_FACTORY = '
|
|
8
|
+
const DEFAULT_MSA_FACTORY = '0x3DEB514B2ac536b8048f5b37182196cf9d5dDD45';
|
|
9
9
|
|
|
10
10
|
// Default PIN Verifier (Groth16Verifier) address - same across all EVM networks via CREATE2
|
|
11
11
|
const DEFAULT_PIN_VERIFIER = '0x65ee46C4d21405f4a4C8e9d0F8a3832c1B885ab4';
|