midnightwalletsync 0.1.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/.env.example +1 -0
- package/README.md +196 -0
- package/bin/midnightsync.js +22 -0
- package/midnightwalletsync.config.json +14 -0
- package/package.json +38 -0
- package/src/cli.ts +147 -0
- package/src/config.ts +139 -0
- package/src/index.ts +4 -0
- package/src/runtime.ts +217 -0
- package/src/snapshot.ts +37 -0
- package/src/types.ts +28 -0
- package/tsconfig.json +13 -0
package/.env.example
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wallet_id_n1=your seed here
|
package/README.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# MidNight-walletsync
|
|
2
|
+
|
|
3
|
+
A lightweight Midnight wallet synchronization SDK and CLI for keeping one or more wallets synced, saving snapshots locally, and querying balances from a running synced process.
|
|
4
|
+
|
|
5
|
+
This package is designed for a local workspace. It is not a public RPC replacement and it does not hold any secrets by itself; it reads seeds from your `.env` file and uses them to build and start wallet instances.
|
|
6
|
+
|
|
7
|
+
## What this SDK does
|
|
8
|
+
|
|
9
|
+
- Builds wallets from seed values
|
|
10
|
+
- Starts and keeps wallets synced to the Midnight network
|
|
11
|
+
- Stores wallet snapshots on disk
|
|
12
|
+
- Exposes a tiny local HTTP server for status and balance queries
|
|
13
|
+
- Supports multiple wallets at the same time using aliases like `n1`, `n2`, `n3`
|
|
14
|
+
- Formats balance output in a readable way
|
|
15
|
+
- Shows NIGHT and DUST with decimal formatting
|
|
16
|
+
|
|
17
|
+
## Why this exists
|
|
18
|
+
|
|
19
|
+
The main idea is to keep a wallet synced once, then reuse that synced wallet state for repeated balance checks and for other local tooling.
|
|
20
|
+
|
|
21
|
+
That means:
|
|
22
|
+
|
|
23
|
+
- you do not need to resync every time
|
|
24
|
+
- another terminal can query the synced wallet
|
|
25
|
+
- other local SDK code can be built on top of the same synced wallet process
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### `.env`
|
|
32
|
+
|
|
33
|
+
Seeds are loaded from environment variables.
|
|
34
|
+
|
|
35
|
+
With the default config, the package expects:
|
|
36
|
+
|
|
37
|
+
- `wallet_id_n1`
|
|
38
|
+
- `wallet_id_n2`
|
|
39
|
+
- `wallet_id_n3`
|
|
40
|
+
|
|
41
|
+
If you change `seedBaseName`, the variable names change too.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
|
|
45
|
+
- `seedBaseName = wallet_id`
|
|
46
|
+
- alias = `n1`
|
|
47
|
+
- expected env key = `wallet_id_n1`
|
|
48
|
+
|
|
49
|
+
## Commands
|
|
50
|
+
|
|
51
|
+
Commands are invoked using `npx midnightsync <command>` in your workspace.
|
|
52
|
+
|
|
53
|
+
For example:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
npx midnightsync sync
|
|
57
|
+
npx midnightsync balance n1
|
|
58
|
+
npx midnightsync status
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or use the npm script shortcuts:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
npm run sync
|
|
65
|
+
npm run balance -- n1
|
|
66
|
+
npm run status
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### `npx midnightsync init`
|
|
70
|
+
|
|
71
|
+
Creates or refreshes:
|
|
72
|
+
|
|
73
|
+
- `midnightwalletsync.config.json`
|
|
74
|
+
- `.env.example`
|
|
75
|
+
- `.env` if missing
|
|
76
|
+
|
|
77
|
+
Use this first in a fresh workspace.
|
|
78
|
+
|
|
79
|
+
### `npx midnightsync sync`
|
|
80
|
+
|
|
81
|
+
Starts the sync runtime.
|
|
82
|
+
|
|
83
|
+
What happens:
|
|
84
|
+
|
|
85
|
+
1. The SDK reads config and seeds
|
|
86
|
+
2. Wallets are built from the seeds
|
|
87
|
+
3. Wallets are started
|
|
88
|
+
4. Sync begins
|
|
89
|
+
5. Snapshots are written into `.midnightwalletsync/`
|
|
90
|
+
6. A local server starts on `http://127.0.0.1:8787`
|
|
91
|
+
|
|
92
|
+
Stop it with Ctrl+C.
|
|
93
|
+
|
|
94
|
+
### `npx midnightsync status`
|
|
95
|
+
|
|
96
|
+
Prints:
|
|
97
|
+
|
|
98
|
+
- config file location
|
|
99
|
+
- env file location
|
|
100
|
+
- wallet aliases currently configured
|
|
101
|
+
|
|
102
|
+
### `npx midnightsync balance <alias>`
|
|
103
|
+
|
|
104
|
+
Reads the latest saved snapshot for a wallet alias and prints balances in a human-friendly format.
|
|
105
|
+
|
|
106
|
+
Default alias is `n1`.
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
npx midnightsync balance n1
|
|
112
|
+
npx midnightsync balance n2
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Local HTTP API
|
|
116
|
+
|
|
117
|
+
When sync is running, the SDK exposes a small local server.
|
|
118
|
+
|
|
119
|
+
### `GET /health`
|
|
120
|
+
|
|
121
|
+
Returns whether the server is up.
|
|
122
|
+
|
|
123
|
+
### `GET /wallets`
|
|
124
|
+
|
|
125
|
+
Returns the configured wallet aliases.
|
|
126
|
+
|
|
127
|
+
### `GET /balance/:alias`
|
|
128
|
+
|
|
129
|
+
Returns the latest snapshot for a wallet alias.
|
|
130
|
+
|
|
131
|
+
Examples:
|
|
132
|
+
|
|
133
|
+
- `http://127.0.0.1:8787/balance/n1`
|
|
134
|
+
- `http://127.0.0.1:8787/balance/n2`
|
|
135
|
+
|
|
136
|
+
Important:
|
|
137
|
+
|
|
138
|
+
- the balance endpoint only returns data when the wallet has fully synced
|
|
139
|
+
- if the wallet is not synced yet, it returns a `503`
|
|
140
|
+
- if the snapshot does not exist, it returns a `404`
|
|
141
|
+
|
|
142
|
+
## Multi-wallet behavior
|
|
143
|
+
|
|
144
|
+
If `walletCount` is greater than 1, the SDK starts multiple wallets in parallel.
|
|
145
|
+
|
|
146
|
+
Each wallet gets an alias:
|
|
147
|
+
|
|
148
|
+
- `n1`
|
|
149
|
+
- `n2`
|
|
150
|
+
- `n3`
|
|
151
|
+
- and so on
|
|
152
|
+
|
|
153
|
+
Each alias maps to a seed key in `.env`.
|
|
154
|
+
|
|
155
|
+
This makes it possible to keep several wallets synced in one process.
|
|
156
|
+
|
|
157
|
+
## Typical workflow
|
|
158
|
+
|
|
159
|
+
1. Run `npx midnightsync init`
|
|
160
|
+
2. Put real seeds into `.env`
|
|
161
|
+
3. Adjust `midnightwalletsync.config.json` if needed
|
|
162
|
+
4. Run `npx midnightsync sync`
|
|
163
|
+
5. Open another terminal
|
|
164
|
+
6. Run `npx midnightsync balance n1`
|
|
165
|
+
7. Query `http://127.0.0.1:8787/balance/n1` if you want JSON output
|
|
166
|
+
|
|
167
|
+
## Can other SDKs use this?
|
|
168
|
+
|
|
169
|
+
Yes, conceptually this SDK can act like a local wallet service.
|
|
170
|
+
|
|
171
|
+
That means another local SDK can:
|
|
172
|
+
|
|
173
|
+
- wait for the wallet to sync
|
|
174
|
+
- read balances and addresses
|
|
175
|
+
- build higher-level logic on top of the synced wallet state
|
|
176
|
+
|
|
177
|
+
## Notes
|
|
178
|
+
|
|
179
|
+
- This package is currently scoped to preprod usage
|
|
180
|
+
- Seeds are required for private wallet control
|
|
181
|
+
- Balance queries are intentionally blocked until sync is complete
|
|
182
|
+
- The local server is meant for localhost use only
|
|
183
|
+
|
|
184
|
+
## Troubleshooting
|
|
185
|
+
|
|
186
|
+
### `wallet is not fully synced yet`
|
|
187
|
+
|
|
188
|
+
Wait for `npm run sync` to finish syncing, then query again.
|
|
189
|
+
|
|
190
|
+
### `Missing seed for n1`
|
|
191
|
+
|
|
192
|
+
Add the correct seed to `.env` using the expected key name.
|
|
193
|
+
|
|
194
|
+
### Nothing prints in balance
|
|
195
|
+
|
|
196
|
+
Check that the wallet alias exists and that the snapshot file is present in `.midnightwalletsync/`.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const rootDir = join(__dirname, '..');
|
|
9
|
+
|
|
10
|
+
// Try to find tsx from package node_modules
|
|
11
|
+
const tsxPath = join(rootDir, 'node_modules', '.bin', 'tsx');
|
|
12
|
+
|
|
13
|
+
const result = spawnSync(tsxPath, [join(rootDir, 'src', 'cli.ts'), ...process.argv.slice(2)], {
|
|
14
|
+
cwd: rootDir,
|
|
15
|
+
env: {
|
|
16
|
+
...process.env,
|
|
17
|
+
NODE_OPTIONS: '--no-deprecation',
|
|
18
|
+
},
|
|
19
|
+
stdio: 'inherit',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
process.exit(result.status ?? 0);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"network": "preprod",
|
|
3
|
+
"seedBaseName": "wallet_id",
|
|
4
|
+
"walletCount": 1,
|
|
5
|
+
"stateDir": ".midnightwalletsync",
|
|
6
|
+
"port": 8787,
|
|
7
|
+
"nightDecimals": 6,
|
|
8
|
+
"dustDecimals": 15,
|
|
9
|
+
"indexer": "https://indexer.preprod.midnight.network/api/v4/graphql",
|
|
10
|
+
"indexerWS": "wss://indexer.preprod.midnight.network/api/v4/graphql/ws",
|
|
11
|
+
"node": "https://rpc.preprod.midnight.network",
|
|
12
|
+
"nodeWS": "wss://rpc.preprod.midnight.network",
|
|
13
|
+
"proofServer": "https://proof.preprod.midnight.network"
|
|
14
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "midnightwalletsync",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin",
|
|
10
|
+
"src",
|
|
11
|
+
"README.md",
|
|
12
|
+
"midnightwalletsync.config.json",
|
|
13
|
+
".env.example",
|
|
14
|
+
"tsconfig.json"
|
|
15
|
+
],
|
|
16
|
+
"bin": {
|
|
17
|
+
"midnightsync": "bin/midnightsync.js"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"init": "NODE_OPTIONS=--no-deprecation tsx src/cli.ts init",
|
|
21
|
+
"sync": "NODE_OPTIONS=--no-deprecation tsx src/cli.ts sync",
|
|
22
|
+
"balance": "NODE_OPTIONS=--no-deprecation tsx src/cli.ts balance",
|
|
23
|
+
"status": "NODE_OPTIONS=--no-deprecation tsx src/cli.ts status",
|
|
24
|
+
"build": "npm run typecheck",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@midnight-ntwrk/ledger-v8": "latest",
|
|
29
|
+
"@midnight-ntwrk/testkit-js": "latest",
|
|
30
|
+
"@midnight-ntwrk/wallet-sdk-address-format": "latest",
|
|
31
|
+
"@midnight-ntwrk/wallet-sdk-facade": "latest",
|
|
32
|
+
"tsx": "latest"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "latest",
|
|
36
|
+
"typescript": "latest"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ensureWorkspaceFiles, envExamplePath, envPath, seedKey, walletAliases } from './config.js';
|
|
4
|
+
import { WalletSyncRuntime } from './runtime.js';
|
|
5
|
+
import type { WalletAlias } from './types.js';
|
|
6
|
+
|
|
7
|
+
const NIGHT_TOKEN_TYPE = '0000000000000000000000000000000000000000000000000000000000000000';
|
|
8
|
+
|
|
9
|
+
function formatUnits(raw: bigint, decimals: number): string {
|
|
10
|
+
if (decimals <= 0) {
|
|
11
|
+
return raw.toString();
|
|
12
|
+
}
|
|
13
|
+
const base = 10n ** BigInt(decimals);
|
|
14
|
+
const whole = raw / base;
|
|
15
|
+
const fraction = raw % base;
|
|
16
|
+
if (fraction === 0n) {
|
|
17
|
+
return whole.toString();
|
|
18
|
+
}
|
|
19
|
+
const fractionText = fraction.toString().padStart(decimals, '0').replace(/0+$/, '');
|
|
20
|
+
return `${whole.toString()}.${fractionText}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function labelToken(token: string): string {
|
|
24
|
+
return token === NIGHT_TOKEN_TYPE ? 'NIGHT' : token;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function usage(): void {
|
|
28
|
+
console.log([
|
|
29
|
+
'MidNight-walletsync commands:',
|
|
30
|
+
' init',
|
|
31
|
+
' sync',
|
|
32
|
+
' status',
|
|
33
|
+
' balance <n1|n2|...>',
|
|
34
|
+
].join('\n'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function printSnapshot(snapshot: any, nightDecimals: number, dustDecimals: number): void {
|
|
38
|
+
const unshieldedBalances = snapshot.unshieldedBalances ?? {};
|
|
39
|
+
const shieldedBalances = snapshot.shieldedBalances ?? {};
|
|
40
|
+
const nightRaw = BigInt(unshieldedBalances[NIGHT_TOKEN_TYPE] ?? '0') + BigInt(shieldedBalances[NIGHT_TOKEN_TYPE] ?? '0');
|
|
41
|
+
const dustRaw = BigInt(snapshot.dustBalanceRaw ?? '0');
|
|
42
|
+
|
|
43
|
+
const labeledUnshielded = Object.fromEntries(
|
|
44
|
+
Object.entries(unshieldedBalances).map(([token, amount]) => [labelToken(token), amount]),
|
|
45
|
+
);
|
|
46
|
+
const labeledShielded = Object.fromEntries(
|
|
47
|
+
Object.entries(shieldedBalances).map(([token, amount]) => [labelToken(token), amount]),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const otherUnshielded = Object.fromEntries(
|
|
51
|
+
Object.entries(unshieldedBalances).filter(([token]) => token !== NIGHT_TOKEN_TYPE),
|
|
52
|
+
);
|
|
53
|
+
const otherShielded = Object.fromEntries(
|
|
54
|
+
Object.entries(shieldedBalances).filter(([token]) => token !== NIGHT_TOKEN_TYPE),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
console.log('=== WALLET BALANCE ===');
|
|
58
|
+
console.log(`alias: ${snapshot.alias}`);
|
|
59
|
+
console.log(`updatedAt: ${snapshot.updatedAt}`);
|
|
60
|
+
console.log(`synced: ${snapshot.isSynced}`);
|
|
61
|
+
console.log(`unshielded address: ${snapshot.unshieldedAddress}`);
|
|
62
|
+
console.log(`shielded address: ${snapshot.shieldedAddress}`);
|
|
63
|
+
console.log(`dust address: ${snapshot.dustAddress}`);
|
|
64
|
+
console.log('');
|
|
65
|
+
console.log('unshielded balances:');
|
|
66
|
+
console.log(JSON.stringify(labeledUnshielded, null, 2));
|
|
67
|
+
console.log(`NIGHT: ${formatUnits(nightRaw, nightDecimals)}`);
|
|
68
|
+
console.log(`DUST: ${formatUnits(dustRaw, dustDecimals)}`);
|
|
69
|
+
console.log('');
|
|
70
|
+
console.log('other unshielded tokens (raw):');
|
|
71
|
+
console.log(JSON.stringify(otherUnshielded, null, 2));
|
|
72
|
+
console.log('other shielded tokens (raw):');
|
|
73
|
+
console.log(JSON.stringify(otherShielded, null, 2));
|
|
74
|
+
console.log('');
|
|
75
|
+
console.log('shielded balances:');
|
|
76
|
+
console.log(JSON.stringify(labeledShielded, null, 2));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function main() {
|
|
80
|
+
const [command, ...args] = process.argv.slice(2);
|
|
81
|
+
const cwd = process.cwd();
|
|
82
|
+
|
|
83
|
+
if (!command) {
|
|
84
|
+
usage();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (command === 'init') {
|
|
89
|
+
const config = ensureWorkspaceFiles(cwd);
|
|
90
|
+
const example = walletAliases(config.walletCount)
|
|
91
|
+
.map((alias) => `${seedKey(config.seedBaseName, alias)}=replace_me`)
|
|
92
|
+
.join('\n');
|
|
93
|
+
writeFileSync(envExamplePath(cwd), `${example}\n`, 'utf8');
|
|
94
|
+
console.log(`Created ${join(cwd, 'midnightwalletsync.config.json')}`);
|
|
95
|
+
console.log(`Created ${envExamplePath(cwd)}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const config = ensureWorkspaceFiles(cwd);
|
|
100
|
+
const runtime = WalletSyncRuntime.fromWorkspace(cwd, config);
|
|
101
|
+
|
|
102
|
+
if (command === 'sync') {
|
|
103
|
+
const server = runtime.createServer();
|
|
104
|
+
const shutdown = async () => {
|
|
105
|
+
server.close();
|
|
106
|
+
await runtime.stopAll();
|
|
107
|
+
process.exit(0);
|
|
108
|
+
};
|
|
109
|
+
const keepAlive = new Promise<void>((resolve) => {
|
|
110
|
+
process.once('SIGINT', () => { void shutdown().finally(resolve); });
|
|
111
|
+
process.once('SIGTERM', () => { void shutdown().finally(resolve); });
|
|
112
|
+
});
|
|
113
|
+
server.listen(config.port, '127.0.0.1', () => {
|
|
114
|
+
console.log(`[info] server listening on http://127.0.0.1:${config.port}`);
|
|
115
|
+
});
|
|
116
|
+
await runtime.startAll();
|
|
117
|
+
console.log('[info] all wallets synced');
|
|
118
|
+
await keepAlive;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (command === 'status') {
|
|
123
|
+
console.log(`Config file: ${join(cwd, 'midnightwalletsync.config.json')}`);
|
|
124
|
+
console.log(`Env file: ${envPath(cwd)}`);
|
|
125
|
+
console.log(`Wallets: ${runtime.listAliases().join(', ')}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (command === 'balance') {
|
|
130
|
+
const alias = (args[0] ?? 'n1') as WalletAlias;
|
|
131
|
+
const snapshotPath = join(cwd, config.stateDir, `${alias}.json`);
|
|
132
|
+
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
|
133
|
+
if (!snapshot.isSynced) {
|
|
134
|
+
console.log('[info] wallet is not fully synced yet');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
printSnapshot(snapshot, config.nightDecimals, config.dustDecimals);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
usage();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
main().catch((error) => {
|
|
145
|
+
console.error('[error]', error);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { MidnightWalletSyncConfig, WalletAlias } from './types.js';
|
|
4
|
+
|
|
5
|
+
export const CONFIG_FILE_NAME = 'midnightwalletsync.config.json';
|
|
6
|
+
export const ENV_FILE_NAME = '.env';
|
|
7
|
+
export const ENV_EXAMPLE_FILE_NAME = '.env.example';
|
|
8
|
+
|
|
9
|
+
export function defaultConfig(): MidnightWalletSyncConfig {
|
|
10
|
+
return {
|
|
11
|
+
network: 'preprod',
|
|
12
|
+
seedBaseName: 'wallet_id',
|
|
13
|
+
walletCount: 3,
|
|
14
|
+
stateDir: '.midnightwalletsync',
|
|
15
|
+
port: 8787,
|
|
16
|
+
nightDecimals: 6,
|
|
17
|
+
dustDecimals: 15,
|
|
18
|
+
indexer: 'https://indexer.preprod.midnight.network/api/v4/graphql',
|
|
19
|
+
indexerWS: 'wss://indexer.preprod.midnight.network/api/v4/graphql/ws',
|
|
20
|
+
node: 'https://rpc.preprod.midnight.network',
|
|
21
|
+
nodeWS: 'wss://rpc.preprod.midnight.network',
|
|
22
|
+
proofServer: 'https://proof.preprod.midnight.network',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function configPath(cwd = process.cwd()): string {
|
|
27
|
+
return join(cwd, CONFIG_FILE_NAME);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function envPath(cwd = process.cwd()): string {
|
|
31
|
+
return join(cwd, ENV_FILE_NAME);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function envExamplePath(cwd = process.cwd()): string {
|
|
35
|
+
return join(cwd, ENV_EXAMPLE_FILE_NAME);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function normalizeConfig(config: Partial<MidnightWalletSyncConfig>): MidnightWalletSyncConfig {
|
|
39
|
+
const base = defaultConfig();
|
|
40
|
+
const walletCount = Number(config.walletCount ?? base.walletCount);
|
|
41
|
+
return {
|
|
42
|
+
network: String(config.network ?? base.network),
|
|
43
|
+
seedBaseName: String(config.seedBaseName ?? base.seedBaseName),
|
|
44
|
+
walletCount: Number.isFinite(walletCount) && walletCount > 0 ? Math.floor(walletCount) : base.walletCount,
|
|
45
|
+
stateDir: String(config.stateDir ?? base.stateDir),
|
|
46
|
+
port: Number(config.port ?? base.port),
|
|
47
|
+
nightDecimals: Number(config.nightDecimals ?? base.nightDecimals),
|
|
48
|
+
dustDecimals: Number(config.dustDecimals ?? base.dustDecimals),
|
|
49
|
+
indexer: String(config.indexer ?? base.indexer),
|
|
50
|
+
indexerWS: String(config.indexerWS ?? base.indexerWS),
|
|
51
|
+
node: String(config.node ?? base.node),
|
|
52
|
+
nodeWS: String(config.nodeWS ?? base.nodeWS),
|
|
53
|
+
proofServer: String(config.proofServer ?? base.proofServer),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function writeDefaultConfig(cwd = process.cwd()): MidnightWalletSyncConfig {
|
|
58
|
+
const config = defaultConfig();
|
|
59
|
+
writeFileSync(configPath(cwd), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
60
|
+
return config;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function ensureConfig(cwd = process.cwd()): MidnightWalletSyncConfig {
|
|
64
|
+
const path = configPath(cwd);
|
|
65
|
+
if (!existsSync(path)) {
|
|
66
|
+
return writeDefaultConfig(cwd);
|
|
67
|
+
}
|
|
68
|
+
const raw = readFileSync(path, 'utf8');
|
|
69
|
+
return normalizeConfig(JSON.parse(raw) as Partial<MidnightWalletSyncConfig>);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseEnvFile(content: string): Record<string, string> {
|
|
73
|
+
const out: Record<string, string> = {};
|
|
74
|
+
for (const line of content.split(/\r?\n/)) {
|
|
75
|
+
const trimmed = line.trim();
|
|
76
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const eq = trimmed.indexOf('=');
|
|
80
|
+
if (eq === -1) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const key = trimmed.slice(0, eq).trim();
|
|
84
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
85
|
+
if (
|
|
86
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
87
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
88
|
+
) {
|
|
89
|
+
value = value.slice(1, -1);
|
|
90
|
+
}
|
|
91
|
+
out[key] = value;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function loadEnvMap(cwd = process.cwd()): Record<string, string> {
|
|
97
|
+
const fromProcess = Object.fromEntries(
|
|
98
|
+
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])),
|
|
99
|
+
);
|
|
100
|
+
const file = envPath(cwd);
|
|
101
|
+
if (!existsSync(file)) {
|
|
102
|
+
return fromProcess;
|
|
103
|
+
}
|
|
104
|
+
const fileEnv = parseEnvFile(readFileSync(file, 'utf8'));
|
|
105
|
+
return {
|
|
106
|
+
...fromProcess,
|
|
107
|
+
...fileEnv,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function seedKey(baseName: string, alias: WalletAlias): string {
|
|
112
|
+
return `${baseName}_${alias}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function resolveSeed(envMap: Record<string, string>, baseName: string, alias: WalletAlias): string {
|
|
116
|
+
const key = seedKey(baseName, alias);
|
|
117
|
+
const direct = envMap[key] ?? envMap[baseName];
|
|
118
|
+
if (!direct) {
|
|
119
|
+
throw new Error(`Missing seed for ${alias}. Expected ${key} in .env`);
|
|
120
|
+
}
|
|
121
|
+
return direct;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function walletAliases(walletCount: number): WalletAlias[] {
|
|
125
|
+
return Array.from({ length: walletCount }, (_, index) => `n${index + 1}` as WalletAlias);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function ensureWorkspaceFiles(cwd = process.cwd()): MidnightWalletSyncConfig {
|
|
129
|
+
const config = ensureConfig(cwd);
|
|
130
|
+
if (!existsSync(envExamplePath(cwd))) {
|
|
131
|
+
const lines = walletAliases(config.walletCount).map((alias) => `${seedKey(config.seedBaseName, alias)}=replace_me`);
|
|
132
|
+
writeFileSync(envExamplePath(cwd), `${lines.join('\n')}\n`, 'utf8');
|
|
133
|
+
}
|
|
134
|
+
if (!existsSync(envPath(cwd))) {
|
|
135
|
+
writeFileSync(envPath(cwd), `# Copy values from .env.example into this file\n`, 'utf8');
|
|
136
|
+
}
|
|
137
|
+
mkdirSync(join(cwd, config.stateDir), { recursive: true });
|
|
138
|
+
return config;
|
|
139
|
+
}
|
package/src/index.ts
ADDED
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { DustSecretKey, LedgerParameters, ZswapSecretKeys } from '@midnight-ntwrk/ledger-v8';
|
|
4
|
+
import { type FacadeState, type WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
|
|
5
|
+
import { MidnightBech32m, ShieldedAddress, UnshieldedAddress } from '@midnight-ntwrk/wallet-sdk-address-format';
|
|
6
|
+
import { type DustWalletOptions, FluentWalletBuilder } from '@midnight-ntwrk/testkit-js';
|
|
7
|
+
import type { MidnightWalletSyncConfig, WalletAlias, WalletSnapshot } from './types.js';
|
|
8
|
+
import { loadEnvMap, resolveSeed, walletAliases } from './config.js';
|
|
9
|
+
import { saveSnapshot, snapshotPath } from './snapshot.js';
|
|
10
|
+
|
|
11
|
+
function formatBalances(balances: Record<string, bigint>): Record<string, string> {
|
|
12
|
+
return Object.fromEntries(Object.entries(balances).map(([token, amount]) => [token, amount.toString()]));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatAddress(networkId: string, address: unknown): string {
|
|
16
|
+
try {
|
|
17
|
+
if (address && typeof address === 'object') {
|
|
18
|
+
return MidnightBech32m.encode(networkId, address as never).asString();
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
// fall through
|
|
22
|
+
}
|
|
23
|
+
return String(address);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function snapshotFromState(alias: WalletAlias, networkId: string, state: any): WalletSnapshot {
|
|
27
|
+
return {
|
|
28
|
+
alias,
|
|
29
|
+
updatedAt: new Date().toISOString(),
|
|
30
|
+
isSynced: Boolean(state.isSynced),
|
|
31
|
+
shieldedBalances: formatBalances(state.shielded.balances),
|
|
32
|
+
unshieldedBalances: formatBalances(state.unshielded.balances),
|
|
33
|
+
dustBalanceRaw: state.dust.balance(new Date()).toString(),
|
|
34
|
+
shieldedAddress: formatAddress(networkId, state.shielded.address),
|
|
35
|
+
unshieldedAddress: formatAddress(networkId, state.unshielded.address),
|
|
36
|
+
dustAddress: formatAddress(networkId, state.dust.address),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createLogger() {
|
|
41
|
+
return {
|
|
42
|
+
info: (...args: unknown[]) => console.log('[info]', ...args),
|
|
43
|
+
debug: (...args: unknown[]) => console.debug('[debug]', ...args),
|
|
44
|
+
error: (...args: unknown[]) => console.error('[error]', ...args),
|
|
45
|
+
} as const;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class WalletService {
|
|
49
|
+
readonly wallet: WalletFacade;
|
|
50
|
+
|
|
51
|
+
private constructor(
|
|
52
|
+
private readonly logger: ReturnType<typeof createLogger>,
|
|
53
|
+
private readonly cwd: string,
|
|
54
|
+
private readonly networkId: string,
|
|
55
|
+
wallet: WalletFacade,
|
|
56
|
+
private readonly shieldedSecretKeys: ZswapSecretKeys,
|
|
57
|
+
private readonly dustSecretKey: DustSecretKey,
|
|
58
|
+
) {
|
|
59
|
+
this.wallet = wallet;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static async build(logger: ReturnType<typeof createLogger>, config: MidnightWalletSyncConfig, seed: string) {
|
|
63
|
+
const environment = {
|
|
64
|
+
walletNetworkId: config.network,
|
|
65
|
+
networkId: config.network,
|
|
66
|
+
indexer: config.indexer,
|
|
67
|
+
indexerWS: config.indexerWS,
|
|
68
|
+
node: config.node,
|
|
69
|
+
nodeWS: config.nodeWS,
|
|
70
|
+
proofServer: config.proofServer,
|
|
71
|
+
};
|
|
72
|
+
const dustOptions: DustWalletOptions = {
|
|
73
|
+
ledgerParams: LedgerParameters.initialParameters(),
|
|
74
|
+
additionalFeeOverhead: 1_000n,
|
|
75
|
+
feeBlocksMargin: 5,
|
|
76
|
+
};
|
|
77
|
+
const walletFacadeBuilder = FluentWalletBuilder.forEnvironment(environment).withDustOptions(dustOptions);
|
|
78
|
+
const buildResult = await walletFacadeBuilder.withSeed(seed).buildWithoutStarting();
|
|
79
|
+
const { wallet, seeds } = buildResult as {
|
|
80
|
+
wallet: WalletFacade;
|
|
81
|
+
seeds: { masterSeed: string; shielded: Uint8Array; dust: Uint8Array };
|
|
82
|
+
};
|
|
83
|
+
logger.info(`Wallet built from seed: ${seeds.masterSeed.slice(0, 8)}...`);
|
|
84
|
+
return new WalletService(
|
|
85
|
+
logger,
|
|
86
|
+
process.cwd(),
|
|
87
|
+
environment.networkId,
|
|
88
|
+
wallet,
|
|
89
|
+
ZswapSecretKeys.fromSeed(seeds.shielded),
|
|
90
|
+
DustSecretKey.fromSeed(seeds.dust),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async start(): Promise<void> {
|
|
95
|
+
this.logger.info('Starting wallet...');
|
|
96
|
+
await this.wallet.start(this.shieldedSecretKeys, this.dustSecretKey);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async stop(): Promise<void> {
|
|
100
|
+
await this.wallet.stop();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async waitSynced(): Promise<FacadeState> {
|
|
104
|
+
return this.wallet.waitForSyncedState();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
subscribeSnapshots(
|
|
108
|
+
alias: WalletAlias,
|
|
109
|
+
stateDir: string,
|
|
110
|
+
onSnapshot?: (snapshot: WalletSnapshot) => void,
|
|
111
|
+
): void {
|
|
112
|
+
this.wallet.state().subscribe((state: any) => {
|
|
113
|
+
const snapshot = snapshotFromState(alias, this.networkId, state);
|
|
114
|
+
saveSnapshot(this.cwd, stateDir, snapshot);
|
|
115
|
+
onSnapshot?.(snapshot);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class WalletSyncRuntime {
|
|
121
|
+
private readonly logger = createLogger();
|
|
122
|
+
private readonly envMap: Record<string, string>;
|
|
123
|
+
private readonly aliases: WalletAlias[];
|
|
124
|
+
private readonly services = new Map<WalletAlias, WalletService>();
|
|
125
|
+
|
|
126
|
+
constructor(
|
|
127
|
+
private readonly cwd: string,
|
|
128
|
+
readonly config: MidnightWalletSyncConfig,
|
|
129
|
+
) {
|
|
130
|
+
this.envMap = loadEnvMap(cwd);
|
|
131
|
+
this.aliases = walletAliases(config.walletCount);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
static fromWorkspace(cwd: string, config: MidnightWalletSyncConfig) {
|
|
135
|
+
return new WalletSyncRuntime(cwd, config);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private seedFor(alias: WalletAlias): string {
|
|
139
|
+
return resolveSeed(this.envMap, this.config.seedBaseName, alias);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async startAll(onSnapshot?: (alias: WalletAlias, snapshot: WalletSnapshot) => void): Promise<void> {
|
|
143
|
+
for (const alias of this.aliases) {
|
|
144
|
+
const service = await WalletService.build(this.logger, this.config, this.seedFor(alias));
|
|
145
|
+
this.services.set(alias, service);
|
|
146
|
+
service.subscribeSnapshots(alias, this.config.stateDir, (snapshot) => onSnapshot?.(alias, snapshot));
|
|
147
|
+
}
|
|
148
|
+
await Promise.all(
|
|
149
|
+
Array.from(this.services.entries()).map(async ([alias, service]) => {
|
|
150
|
+
await service.start();
|
|
151
|
+
await service.waitSynced();
|
|
152
|
+
this.logger.info(`Wallet ${alias} synced`);
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async stopAll(): Promise<void> {
|
|
158
|
+
await Promise.all(Array.from(this.services.values()).map((service) => service.stop()));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
readSnapshot(alias: WalletAlias): WalletSnapshot | null {
|
|
162
|
+
const path = snapshotPath(this.cwd, this.config.stateDir, alias);
|
|
163
|
+
try {
|
|
164
|
+
return JSON.parse(readFileSync(path, 'utf8')) as WalletSnapshot;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
listAliases(): WalletAlias[] {
|
|
171
|
+
return [...this.aliases];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
get port(): number {
|
|
175
|
+
return this.config.port;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
createServer() {
|
|
179
|
+
return createServer(async (req, res) => {
|
|
180
|
+
const requestUrl = new URL(req.url ?? '/', `http://127.0.0.1:${this.port}`);
|
|
181
|
+
const pathParts = requestUrl.pathname.split('/').filter(Boolean);
|
|
182
|
+
|
|
183
|
+
if (requestUrl.pathname === '/health') {
|
|
184
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
185
|
+
res.end(JSON.stringify({ ok: true }));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (requestUrl.pathname === '/wallets') {
|
|
190
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
191
|
+
res.end(JSON.stringify({ ok: true, wallets: this.listAliases() }, null, 2));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (pathParts[0] === 'balance' && pathParts.length === 2) {
|
|
196
|
+
const alias = pathParts[1] as WalletAlias;
|
|
197
|
+
const snapshot = this.readSnapshot(alias);
|
|
198
|
+
if (!snapshot) {
|
|
199
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
200
|
+
res.end(JSON.stringify({ ok: false, message: `No snapshot for ${alias}` }));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!snapshot.isSynced) {
|
|
204
|
+
res.writeHead(503, { 'content-type': 'application/json' });
|
|
205
|
+
res.end(JSON.stringify({ ok: false, message: 'Wallet not fully synced yet.' }));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
209
|
+
res.end(JSON.stringify({ ok: true, ...snapshot }, null, 2));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
214
|
+
res.end(JSON.stringify({ ok: false, message: 'Use /health, /wallets, or /balance/:alias' }));
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { WalletAlias, WalletSnapshot } from './types.js';
|
|
4
|
+
|
|
5
|
+
export function snapshotDirPath(cwd: string, stateDir: string): string {
|
|
6
|
+
return join(cwd, stateDir);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function snapshotPath(cwd: string, stateDir: string, alias: WalletAlias): string {
|
|
10
|
+
return join(snapshotDirPath(cwd, stateDir), `${alias}.json`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function saveSnapshot(cwd: string, stateDir: string, snapshot: WalletSnapshot): void {
|
|
14
|
+
const dir = snapshotDirPath(cwd, stateDir);
|
|
15
|
+
mkdirSync(dir, { recursive: true });
|
|
16
|
+
writeFileSync(snapshotPath(cwd, stateDir, snapshot.alias), `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function loadSnapshot(
|
|
20
|
+
cwd: string,
|
|
21
|
+
stateDir: string,
|
|
22
|
+
alias: WalletAlias,
|
|
23
|
+
): WalletSnapshot | null {
|
|
24
|
+
const path = snapshotPath(cwd, stateDir, alias);
|
|
25
|
+
if (!existsSync(path)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return JSON.parse(readFileSync(path, 'utf8')) as WalletSnapshot;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function listSavedSnapshots(cwd: string, stateDir: string): WalletSnapshot[] {
|
|
32
|
+
const dir = snapshotDirPath(cwd, stateDir);
|
|
33
|
+
if (!existsSync(dir)) {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
return [];
|
|
37
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type MidnightWalletSyncConfig = {
|
|
2
|
+
network: string;
|
|
3
|
+
seedBaseName: string;
|
|
4
|
+
walletCount: number;
|
|
5
|
+
stateDir: string;
|
|
6
|
+
port: number;
|
|
7
|
+
nightDecimals: number;
|
|
8
|
+
dustDecimals: number;
|
|
9
|
+
indexer: string;
|
|
10
|
+
indexerWS: string;
|
|
11
|
+
node: string;
|
|
12
|
+
nodeWS: string;
|
|
13
|
+
proofServer: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type WalletAlias = `n${number}`;
|
|
17
|
+
|
|
18
|
+
export type WalletSnapshot = {
|
|
19
|
+
alias: WalletAlias;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
isSynced: boolean;
|
|
22
|
+
shieldedBalances: Record<string, string>;
|
|
23
|
+
unshieldedBalances: Record<string, string>;
|
|
24
|
+
dustBalanceRaw: string;
|
|
25
|
+
shieldedAddress: string;
|
|
26
|
+
unshieldedAddress: string;
|
|
27
|
+
dustAddress: string;
|
|
28
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"types": ["node"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*.ts"]
|
|
13
|
+
}
|